From b487f62ba7cd7dbbcadb2b5704ec1c99f6578390 Mon Sep 17 00:00:00 2001 From: Tim Keller Date: Tue, 6 May 2025 21:17:38 -0500 Subject: initial commit --- .gitignore | 2 ++ pix.py | 89 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ transition.py | 67 ++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 158 insertions(+) create mode 100644 .gitignore create mode 100644 pix.py create mode 100644 transition.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..cf1238c --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +__pycache__ +*.jpg diff --git a/pix.py b/pix.py new file mode 100644 index 0000000..988f83d --- /dev/null +++ b/pix.py @@ -0,0 +1,89 @@ +import sys +from OpenGL.GL import * +from OpenGL.GLUT import * +from OpenGL.GLU import * +from PIL import Image +from time import time + +from transition import Transition + +display_time = 2.0 +transition_duration = 0.5 + +# Load image as a texture using PIL +def load_texture(image_path): + img = Image.open(image_path) + img = img.convert("RGBA") # Ensure the image is in RGBA mode (4 channels: R, G, B, A) + img_data = img.tobytes("raw", "RGBA", 0, -1) # Convert image data to bytes + + texture_id = glGenTextures(1) + glBindTexture(GL_TEXTURE_2D, texture_id) + glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, img.width, img.height, 0, GL_RGBA, GL_UNSIGNED_BYTE, img_data) + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR) + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR) + return texture_id + +# Main display function +transition = Transition() +def display(): + global start_time, image_time, last_time, alpha, texture_id1, texture_id2 + + current_time = time() + alive_time = current_time - start_time + delta_time = current_time - last_time + last_time = current_time + image_time += delta_time + + if (image_time < display_time): + return + + glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT) + glLoadIdentity() + + # Get window size + window_width, window_height = glutGet(GLUT_WINDOW_WIDTH), glutGet(GLUT_WINDOW_HEIGHT) + + # DRAW + transition_time = image_time - display_time + complete = transition.draw(texture_id1, texture_id2, window_width, window_height, delta_time, transition_time, transition_duration) + + glClearColor(0.0, 0.0, 0.0, 1.0) # Set the background color to black + + glutSwapBuffers() + + if (complete): + image_time = 0 + texture_id1, texture_id2 = texture_id2, texture_id1 + +# Initialization and main loop +def main(): + global texture_id1, texture_id2, alpha, last_time, start_time, image_time + + # Initialize the window + glutInit(sys.argv) + glutInitDisplayMode(GLUT_DOUBLE | GLUT_RGB) + glutCreateWindow("Image Viewer with Fade Transition") + glEnable(GL_TEXTURE_2D) + + # Load two images for transition + texture_id1 = load_texture("image1.jpg") + texture_id2 = load_texture("image2.jpg") + + alpha = 0.0 # Start with fully transparent + image_time = 0 + start_time = time() + last_time = time() + + # Set up the OpenGL viewport and projection + glMatrixMode(GL_PROJECTION) + glLoadIdentity() + glOrtho(-1, 1, -1, 1, -1, 1) + glMatrixMode(GL_MODELVIEW) + + glutDisplayFunc(display) + glutIdleFunc(display) + glutMainLoop() + +if __name__ == "__main__": + main() + diff --git a/transition.py b/transition.py new file mode 100644 index 0000000..cf2acb4 --- /dev/null +++ b/transition.py @@ -0,0 +1,67 @@ +from OpenGL.GL import * +from OpenGL.GLUT import * +from OpenGL.GLU import * + +class Transition: + def draw(self, texture_prev, texture_next, window_width, window_height, delta_time, transition_time, transition_duration): + # Update alpha value for fade effect + alpha = transition_time / transition_duration + if alpha > 1.0: + alpha = 1.0 + + # Draw the first image + self._draw_image(texture_prev, 3840, 2160, window_width, window_height, 1 - alpha) # TODO instead of decreasing alpha, draw transparent letterboxes + # Draw the second image (with transparency) + self._draw_image(texture_next, 3840, 2160, window_width, window_height, alpha) + + return alpha >= 1.0 # Complete + + + + # Draw the image with blending enabled (to allow fade effect) + def _draw_image(self, texture_id, img_width, img_height, window_width, window_height, alpha): + if (not alpha): return + + glEnable(GL_BLEND) + glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA) + glBindTexture(GL_TEXTURE_2D, texture_id) + glColor4f(1.0, 1.0, 1.0, alpha) # Set alpha to control transparency + + # Calculate aspect ratio + img_aspect = img_width / float(img_height) + win_aspect = window_width / float(window_height) + + scaled_width = window_width + scaled_height = window_height + + # Scale the image to fit inside the window while maintaining aspect ratio + if img_aspect > win_aspect: + # Image is wider than window, letterbox vertically + scaled_height = window_width / img_aspect + else: + # Image is taller than window, letterbox horizontally + scaled_width = window_height * img_aspect + + # Position the image so it is centered + offset_x = (window_width - scaled_width) / 2 + offset_y = (window_height - scaled_height) / 2 + + # Normalize coordinates to range from -1 to 1 + x1 = (offset_x / window_width ) * 2 - 1 + y1 = (offset_y / window_height) * 2 - 1 + x2 = ((offset_x + scaled_width ) / window_width ) * 2 - 1 + y2 = ((offset_y + scaled_height) / window_height) * 2 - 1 + + # Draw the image in the center with scaling + glBegin(GL_QUADS) + glTexCoord2f(0, 0) + glVertex2f(x1, y1) + glTexCoord2f(1, 0) + glVertex2f(x2, y1) + glTexCoord2f(1, 1) + glVertex2f(x2, y2) + glTexCoord2f(0, 1) + glVertex2f(x1, y2) + glEnd() + + glDisable(GL_BLEND) -- cgit v1.2.3 From 145de3311cb9f32ac0bc9842b0600fcad84fed16 Mon Sep 17 00:00:00 2001 From: Tim Keller Date: Fri, 9 May 2025 21:58:54 -0500 Subject: add support for downloading new images from immich api on a separate thread --- immich.py | 33 ++++++++++++++++++ pix.py | 97 ++++++++------------------------------------------- texture.py | 43 +++++++++++++++++++++++ transition.py | 12 +++---- window.py | 110 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 206 insertions(+), 89 deletions(-) create mode 100644 immich.py create mode 100644 texture.py create mode 100644 window.py diff --git a/immich.py b/immich.py new file mode 100644 index 0000000..1f1ccb8 --- /dev/null +++ b/immich.py @@ -0,0 +1,33 @@ +import requests +from io import BytesIO + +from texture import ImageTexture + + +class ImmichConnector: + def __init__(self, server_url, api_key): + self.server_url = server_url.removesuffix("/") + self.api_key = api_key + + def _request(self, endpoint): + return requests.get(f"{self.server_url}/api/{endpoint}", headers={ "x-api-key": self.api_key }) + + def load_album_assets(self, key): + response = self._request(f"albums/{key}") + if response.status_code != 200: return + + data = response.json() + return data["assets"] + + def load_image(self, pd, key, exif=None): + response = self._request(f"assets/{key}/thumbnail?size=fullsize") + if response.status_code != 200: return + + image_data = BytesIO(response.content) + it = ImageTexture(image_data, exif) + pd.textures.append(it) + print(f"Loaded image {key}") + + def idle(self, pd): + for asset in self.load_album_assets("bac029a5-972b-4519-bce0-a0d74add3969"): + self.load_image(pd, asset["id"], asset["exifInfo"]) diff --git a/pix.py b/pix.py index 988f83d..41d28d6 100644 --- a/pix.py +++ b/pix.py @@ -1,89 +1,20 @@ import sys -from OpenGL.GL import * -from OpenGL.GLUT import * -from OpenGL.GLU import * -from PIL import Image -from time import time +from threading import Thread -from transition import Transition - -display_time = 2.0 -transition_duration = 0.5 - -# Load image as a texture using PIL -def load_texture(image_path): - img = Image.open(image_path) - img = img.convert("RGBA") # Ensure the image is in RGBA mode (4 channels: R, G, B, A) - img_data = img.tobytes("raw", "RGBA", 0, -1) # Convert image data to bytes - - texture_id = glGenTextures(1) - glBindTexture(GL_TEXTURE_2D, texture_id) - glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, img.width, img.height, 0, GL_RGBA, GL_UNSIGNED_BYTE, img_data) - glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR) - glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR) - return texture_id - -# Main display function -transition = Transition() -def display(): - global start_time, image_time, last_time, alpha, texture_id1, texture_id2 - - current_time = time() - alive_time = current_time - start_time - delta_time = current_time - last_time - last_time = current_time - image_time += delta_time - - if (image_time < display_time): - return - - glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT) - glLoadIdentity() - - # Get window size - window_width, window_height = glutGet(GLUT_WINDOW_WIDTH), glutGet(GLUT_WINDOW_HEIGHT) - - # DRAW - transition_time = image_time - display_time - complete = transition.draw(texture_id1, texture_id2, window_width, window_height, delta_time, transition_time, transition_duration) - - glClearColor(0.0, 0.0, 0.0, 1.0) # Set the background color to black - - glutSwapBuffers() - - if (complete): - image_time = 0 - texture_id1, texture_id2 = texture_id2, texture_id1 - -# Initialization and main loop -def main(): - global texture_id1, texture_id2, alpha, last_time, start_time, image_time - - # Initialize the window - glutInit(sys.argv) - glutInitDisplayMode(GLUT_DOUBLE | GLUT_RGB) - glutCreateWindow("Image Viewer with Fade Transition") - glEnable(GL_TEXTURE_2D) +from window import PixDisplay +from immich import ImmichConnector +def load_textures(pd): # Load two images for transition - texture_id1 = load_texture("image1.jpg") - texture_id2 = load_texture("image2.jpg") - - alpha = 0.0 # Start with fully transparent - image_time = 0 - start_time = time() - last_time = time() - - # Set up the OpenGL viewport and projection - glMatrixMode(GL_PROJECTION) - glLoadIdentity() - glOrtho(-1, 1, -1, 1, -1, 1) - glMatrixMode(GL_MODELVIEW) - - glutDisplayFunc(display) - glutIdleFunc(display) - glutMainLoop() + tex = ImageTexture("image1.jpg") + pd.textures.append(tex) + tex = ImageTexture("image2.jpg") + pd.textures.append(tex) if __name__ == "__main__": - main() - + immichConnector = ImmichConnector("http://192.168.1.13", "m5nqOoBc4uhAba21gZdCP3z8D3JT4GPxDXL2psd52EA") + pd = PixDisplay() + #t1 = Thread(target=load_textures, daemon=True, args=(pd,)) + t1 = Thread(target=immichConnector.idle, daemon=True, args=(pd,)) + t1.start() + pd.main() diff --git a/texture.py b/texture.py new file mode 100644 index 0000000..cf37d6a --- /dev/null +++ b/texture.py @@ -0,0 +1,43 @@ +from OpenGL.GL import * +from OpenGL.GLUT import * +from OpenGL.GLU import * +from PIL import Image, ExifTags + +class ImageTexture: + def __init__(self, image_source, exif=None): + self.id = None + img = Image.open(image_source) + self.exif = exif or ImageTexture.get_exif(img) + img = ImageTexture.handle_orientation(img, exif) + img = img.convert("RGBA") # Ensure the image is in RGBA mode + self.width = img.width + self.height = img.height + self._img_data = img.tobytes("raw", "RGBA", 0, -1) # Convert image data to bytes + + def gl_init(self): + if self.id: + return + #glPixelStorei(GL_UNPACK_ALIGNMENT, 1) + self.id = glGenTextures(1) + glBindTexture(GL_TEXTURE_2D, self.id) + glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, self.width, self.height, 0, GL_RGBA, GL_UNSIGNED_BYTE, self._img_data) + #glTexImage2D(GL_TEXTURE_2D, 0, GL_RGB, img.width, img.height, 0, GL_RGB, GL_UNSIGNED_BYTE, img_data) + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR) + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR) + del self._img_data # No longer needed, clear mem + + print(f"Loaded texture {self.id}") + + @staticmethod + def get_exif(img): + return { ExifTags.TAGS[k]: v for k, v in img.getexif().items() if k in ExifTags } + + @staticmethod + def handle_orientation(img, exif): + orientation = exif.get("Orientation", 1) + + if orientation == 3: return img.rotate(180, expand=True) + if orientation == 6: return img.rotate(270, expand=True) + if orientation == 8: return img.rotate( 90, expand=True) + + return img diff --git a/transition.py b/transition.py index cf2acb4..a076889 100644 --- a/transition.py +++ b/transition.py @@ -10,25 +10,25 @@ class Transition: alpha = 1.0 # Draw the first image - self._draw_image(texture_prev, 3840, 2160, window_width, window_height, 1 - alpha) # TODO instead of decreasing alpha, draw transparent letterboxes + self.draw_image(texture_prev, window_width, window_height, 1 - alpha) # TODO instead of decreasing alpha, draw transparent letterboxes # Draw the second image (with transparency) - self._draw_image(texture_next, 3840, 2160, window_width, window_height, alpha) + self.draw_image(texture_next, window_width, window_height, alpha) return alpha >= 1.0 # Complete # Draw the image with blending enabled (to allow fade effect) - def _draw_image(self, texture_id, img_width, img_height, window_width, window_height, alpha): - if (not alpha): return + def draw_image(self, texture, window_width, window_height, alpha): + if not alpha: return glEnable(GL_BLEND) glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA) - glBindTexture(GL_TEXTURE_2D, texture_id) + glBindTexture(GL_TEXTURE_2D, texture.id) glColor4f(1.0, 1.0, 1.0, alpha) # Set alpha to control transparency # Calculate aspect ratio - img_aspect = img_width / float(img_height) + img_aspect = texture.width / float(texture.height) win_aspect = window_width / float(window_height) scaled_width = window_width diff --git a/window.py b/window.py new file mode 100644 index 0000000..29d0522 --- /dev/null +++ b/window.py @@ -0,0 +1,110 @@ +from OpenGL.GL import * +from OpenGL.GLUT import * +from OpenGL.GLU import * +from time import time + +from transition import Transition +from texture import ImageTexture + + +class PixDisplay: + def __init__(self): + self.last_time = 0 + self.start_time = 0 + self.image_time = 0 + self.textures = [] + self.current_texture_index = 0 + self.transition = Transition() + self.window_width = 0 + self.window_height = 0 + + self.display_duration = 2.0 + self.transition_duration = 0.5 + + @property + def next_texture_index(self): return (self.current_texture_index + 1) % len(self.textures) + + @property + def texture_current(self): return self.textures[self.current_texture_index] + + @property + def texture_next(self): return self.textures[self.next_texture_index] + + # Main display function + def display(self): + # Calculate timings + current_time = time() + alive_time = current_time - self.start_time + delta_time = current_time - self.last_time + self.last_time = current_time + + if not self.textures: + glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT) + glLoadIdentity() + glClearColor(0.0, 0.0, 0.0, 1.0) # Set the background color to black + + glutSwapBuffers() + return + + # Ensure textures are initialized + self.texture_current.gl_init() + self.texture_next.gl_init() + + # Progress image time + self.image_time += delta_time + + # Get window size + window_width, window_height = glutGet(GLUT_WINDOW_WIDTH), glutGet(GLUT_WINDOW_HEIGHT) + + if self.image_time < self.display_duration: + if window_width != self.window_width or window_height != self.window_height: + self.window_width, self.window_height = window_width, window_height + + glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT) + glLoadIdentity() + + self.transition.draw_image(self.texture_current, self.window_width, self.window_height, 1) + + glClearColor(0.0, 0.0, 0.0, 1.0) # Set the background color to black + glutSwapBuffers() + return + + self.window_width, self.window_height = window_width, window_height + + glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT) + glLoadIdentity() + + # DRAW + transition_time = self.image_time - self.display_duration + complete = self.transition.draw(self.texture_current, self.texture_next, self.window_width, self.window_height, delta_time, transition_time, self.transition_duration) + + glClearColor(0.0, 0.0, 0.0, 1.0) # Set the background color to black + + glutSwapBuffers() + + if complete: + self.image_time = 0 + self.current_texture_index = self.next_texture_index + + # Initialization and main loop + def main(self): + # Initialize the window + glutInit(sys.argv) + glutInitDisplayMode(GLUT_DOUBLE | GLUT_RGB) + glutCreateWindow("Image Viewer with Fade Transition") + glEnable(GL_TEXTURE_2D) + + self.image_time = 0 + self.start_time = time() + self.last_time = time() + + # Set up the OpenGL viewport and projection + glMatrixMode(GL_PROJECTION) + glLoadIdentity() + glOrtho(-1, 1, -1, 1, -1, 1) + glMatrixMode(GL_MODELVIEW) + + glutDisplayFunc(self.display) + glutIdleFunc(self.display) + glutMainLoop() + -- cgit v1.2.3 From 568a87f44a674276e6e55f9302cc9e44a0929f71 Mon Sep 17 00:00:00 2001 From: Tim Keller Date: Fri, 9 May 2025 22:04:15 -0500 Subject: get exif correctly --- texture.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/texture.py b/texture.py index cf37d6a..73b7a84 100644 --- a/texture.py +++ b/texture.py @@ -8,7 +8,7 @@ class ImageTexture: self.id = None img = Image.open(image_source) self.exif = exif or ImageTexture.get_exif(img) - img = ImageTexture.handle_orientation(img, exif) + img = ImageTexture.handle_orientation(img, self.exif) img = img.convert("RGBA") # Ensure the image is in RGBA mode self.width = img.width self.height = img.height @@ -30,11 +30,11 @@ class ImageTexture: @staticmethod def get_exif(img): - return { ExifTags.TAGS[k]: v for k, v in img.getexif().items() if k in ExifTags } + return { ExifTags.TAGS[k]: v for k, v in img.getexif().items() if k in ExifTags.TAGS } @staticmethod def handle_orientation(img, exif): - orientation = exif.get("Orientation", 1) + orientation = exif.get("Orientation", 1) if exif is not None else 1 if orientation == 3: return img.rotate(180, expand=True) if orientation == 6: return img.rotate(270, expand=True) -- cgit v1.2.3 From 2bd4da7678b027843cac1a03a1b5f6cc70a7cc81 Mon Sep 17 00:00:00 2001 From: Tim Keller Date: Sat, 10 May 2025 11:52:02 -0500 Subject: requirements.txt added, significantly reduced cpu usage using timer func and vertex/fragment shader instead of drawing new quad etc every frame --- pix.py | 8 --- renderer.py | 152 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ requirements.txt | 5 ++ transition.py | 67 ------------------------ window.py | 37 +++++++++----- 5 files changed, 182 insertions(+), 87 deletions(-) create mode 100644 renderer.py create mode 100644 requirements.txt delete mode 100644 transition.py diff --git a/pix.py b/pix.py index 41d28d6..1e00fcf 100644 --- a/pix.py +++ b/pix.py @@ -4,17 +4,9 @@ from threading import Thread from window import PixDisplay from immich import ImmichConnector -def load_textures(pd): - # Load two images for transition - tex = ImageTexture("image1.jpg") - pd.textures.append(tex) - tex = ImageTexture("image2.jpg") - pd.textures.append(tex) - if __name__ == "__main__": immichConnector = ImmichConnector("http://192.168.1.13", "m5nqOoBc4uhAba21gZdCP3z8D3JT4GPxDXL2psd52EA") pd = PixDisplay() - #t1 = Thread(target=load_textures, daemon=True, args=(pd,)) t1 = Thread(target=immichConnector.idle, daemon=True, args=(pd,)) t1.start() pd.main() diff --git a/renderer.py b/renderer.py new file mode 100644 index 0000000..92d0686 --- /dev/null +++ b/renderer.py @@ -0,0 +1,152 @@ +import numpy as np +from OpenGL.GL import * +from OpenGL.GLUT import * +from OpenGL.GLU import * +from typing import Protocol + +class ImageRenderer: + def __init__(self): + # Setup shader and quad + self.shader = self._init_shader() + self._init_quad() + + # Get uniform locations from the shader + self.uTransform = glGetUniformLocation(self.shader, "uTransform") + self.uAlpha = glGetUniformLocation(self.shader, "uAlpha") + + # Use the shader program and set default texture unit to 0 + glUseProgram(self.shader) + glUniform1i(glGetUniformLocation(self.shader, "uTexture"), 0) + + # Setup transition + self.transition = None + + def set_transition(self, transition): + self.transition = transition(self) + + @staticmethod + def compile_shader(source, shader_type): + shader = glCreateShader(shader_type) # Create a new shader object + glShaderSource(shader, source) # Attach the shader source code + glCompileShader(shader) # Compile the shader + if not glGetShaderiv(shader, GL_COMPILE_STATUS): # Check if compilation succeeded + raise RuntimeError(glGetShaderInfoLog(shader).decode()) # Raise error with log if failed + return shader + + def _init_shader(self): + vertex_src = """ + #version 330 core + layout (location = 0) in vec2 aPos; // Vertex position + layout (location = 1) in vec2 aTexCoord; // Texture coordinates + uniform mat4 uTransform; // Transformation matrix + out vec2 TexCoord; // Output texture coordinate + + void main() { + gl_Position = uTransform * vec4(aPos, 0.0, 1.0); // Apply transformation + TexCoord = aTexCoord; // Pass tex coord to fragment shader + } + """ + fragment_src = """ + #version 330 core + in vec2 TexCoord; // Interpolated texture coordinates + out vec4 FragColor; // Final fragment color + uniform sampler2D uTexture; // Texture sampler + uniform float uAlpha; // Global alpha for transparency + + void main() { + vec4 texColor = texture(uTexture, TexCoord); // Sample texture + FragColor = vec4(texColor.rgb, texColor.a * uAlpha); // Apply alpha blending + } + """ + + # Compile and link shaders into a program + vs = ImageRenderer.compile_shader(vertex_src, GL_VERTEX_SHADER) + fs = ImageRenderer.compile_shader(fragment_src, GL_FRAGMENT_SHADER) + prog = glCreateProgram() + glAttachShader(prog, vs) + glAttachShader(prog, fs) + glLinkProgram(prog) + return prog + + def _init_quad(self): + # Define a full-screen quad with positions and texture coordinates + quad = np.array([ + -1, -1, 0, 0, # Bottom-left + 1, -1, 1, 0, # Bottom-right + -1, 1, 0, 1, # Top-left + 1, 1, 1, 1, # Top-right + ], dtype=np.float32) + + # Create and bind a Vertex Array Object + self.vao = glGenVertexArrays(1) + vbo = glGenBuffers(1) + glBindVertexArray(self.vao) + glBindBuffer(GL_ARRAY_BUFFER, vbo) + glBufferData(GL_ARRAY_BUFFER, quad.nbytes, quad, GL_STATIC_DRAW) + + # Setup vertex attributes: position (location = 0) + glVertexAttribPointer(0, 2, GL_FLOAT, GL_FALSE, 16, ctypes.c_void_p(0)) + glEnableVertexAttribArray(0) + + # Setup vertex attributes: texture coordinates (location = 1) + glVertexAttribPointer(1, 2, GL_FLOAT, GL_FALSE, 16, ctypes.c_void_p(8)) + glEnableVertexAttribArray(1) + + def draw_static(self, tex, win_w, win_h, alpha): + glUseProgram(self.shader) + glBindVertexArray(self.vao) + glBindTexture(GL_TEXTURE_2D, tex.id) + + # Calculate aspect ratios + img_aspect = tex.width / tex.height + win_aspect = win_w / win_h + + # Calculate scaling factors to preserve image aspect ratio + if img_aspect > win_aspect: + sx = 1.0 + sy = win_aspect / img_aspect + else: + sx = img_aspect / win_aspect + sy = 1.0 + + # Create transformation matrix for aspect-ratio-correct rendering + transform = np.array([ + [sx, 0, 0, 0], + [0, sy, 0, 0], + [0, 0, 1, 0], + [0, 0, 0, 1], + ], dtype=np.float32) + + # Pass transformation and alpha to the shader + glUniformMatrix4fv(self.uTransform, 1, GL_FALSE, transform) + glUniform1f(self.uAlpha, alpha) + + # Draw the textured quad + glDrawArrays(GL_TRIANGLE_STRIP, 0, 4) + + def draw_transition(self, tex_start, tex_end, win_w, win_h, delta_time, transition_time, transition_duration): + assert self.transition, "No transition has been set" + return self.transition.draw(tex_start, tex_end, win_w, win_h, delta_time, transition_time, transition_duration) + + +class Transition(Protocol): + def __init__(self, renderer): + self.renderer = renderer + + def draw(self, tex_start, tex_end, win_w, win_h, delta_time, transition_time, transition_duration): + pass + + +class TransitionMix(Transition): + def draw(self, tex_start, tex_end, win_w, win_h, delta_time, transition_time, transition_duration): + # Update alpha value for fade effect + alpha = transition_time / transition_duration + if alpha > 1.0: + alpha = 1.0 + + # Draw the first image + self.renderer.draw_static(tex_start, win_w, win_h, 1 - alpha) # TODO instead of decreasing alpha, draw transparent letterboxes + # Draw the second image (with transparency) + self.renderer.draw_static(tex_end, win_w, win_h, alpha) + + return alpha >= 1.0 # Complete diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..7d9001a --- /dev/null +++ b/requirements.txt @@ -0,0 +1,5 @@ +flask +numpy +pillow +pyopengl +requests diff --git a/transition.py b/transition.py deleted file mode 100644 index a076889..0000000 --- a/transition.py +++ /dev/null @@ -1,67 +0,0 @@ -from OpenGL.GL import * -from OpenGL.GLUT import * -from OpenGL.GLU import * - -class Transition: - def draw(self, texture_prev, texture_next, window_width, window_height, delta_time, transition_time, transition_duration): - # Update alpha value for fade effect - alpha = transition_time / transition_duration - if alpha > 1.0: - alpha = 1.0 - - # Draw the first image - self.draw_image(texture_prev, window_width, window_height, 1 - alpha) # TODO instead of decreasing alpha, draw transparent letterboxes - # Draw the second image (with transparency) - self.draw_image(texture_next, window_width, window_height, alpha) - - return alpha >= 1.0 # Complete - - - - # Draw the image with blending enabled (to allow fade effect) - def draw_image(self, texture, window_width, window_height, alpha): - if not alpha: return - - glEnable(GL_BLEND) - glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA) - glBindTexture(GL_TEXTURE_2D, texture.id) - glColor4f(1.0, 1.0, 1.0, alpha) # Set alpha to control transparency - - # Calculate aspect ratio - img_aspect = texture.width / float(texture.height) - win_aspect = window_width / float(window_height) - - scaled_width = window_width - scaled_height = window_height - - # Scale the image to fit inside the window while maintaining aspect ratio - if img_aspect > win_aspect: - # Image is wider than window, letterbox vertically - scaled_height = window_width / img_aspect - else: - # Image is taller than window, letterbox horizontally - scaled_width = window_height * img_aspect - - # Position the image so it is centered - offset_x = (window_width - scaled_width) / 2 - offset_y = (window_height - scaled_height) / 2 - - # Normalize coordinates to range from -1 to 1 - x1 = (offset_x / window_width ) * 2 - 1 - y1 = (offset_y / window_height) * 2 - 1 - x2 = ((offset_x + scaled_width ) / window_width ) * 2 - 1 - y2 = ((offset_y + scaled_height) / window_height) * 2 - 1 - - # Draw the image in the center with scaling - glBegin(GL_QUADS) - glTexCoord2f(0, 0) - glVertex2f(x1, y1) - glTexCoord2f(1, 0) - glVertex2f(x2, y1) - glTexCoord2f(1, 1) - glVertex2f(x2, y2) - glTexCoord2f(0, 1) - glVertex2f(x1, y2) - glEnd() - - glDisable(GL_BLEND) diff --git a/window.py b/window.py index 29d0522..4c51bd1 100644 --- a/window.py +++ b/window.py @@ -3,7 +3,7 @@ from OpenGL.GLUT import * from OpenGL.GLU import * from time import time -from transition import Transition +from renderer import ImageRenderer, TransitionMix from texture import ImageTexture @@ -14,9 +14,12 @@ class PixDisplay: self.image_time = 0 self.textures = [] self.current_texture_index = 0 - self.transition = Transition() + self.renderer = None self.window_width = 0 self.window_height = 0 + # TODO + self.max_framerate = 60 + self.frame_time = int(1000 / self.max_framerate) # In ms self.display_duration = 2.0 self.transition_duration = 0.5 @@ -39,9 +42,9 @@ class PixDisplay: self.last_time = current_time if not self.textures: - glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT) - glLoadIdentity() glClearColor(0.0, 0.0, 0.0, 1.0) # Set the background color to black + glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT) + #glLoadIdentity() glutSwapBuffers() return @@ -60,25 +63,24 @@ class PixDisplay: if window_width != self.window_width or window_height != self.window_height: self.window_width, self.window_height = window_width, window_height + glClearColor(0.0, 0.0, 0.0, 1.0) # Set the background color to black glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT) - glLoadIdentity() + #glLoadIdentity() - self.transition.draw_image(self.texture_current, self.window_width, self.window_height, 1) + self.renderer.draw_static(self.texture_current, self.window_width, self.window_height, 1) - glClearColor(0.0, 0.0, 0.0, 1.0) # Set the background color to black glutSwapBuffers() return self.window_width, self.window_height = window_width, window_height + glClearColor(0.0, 0.0, 0.0, 1.0) # Set the background color to black glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT) - glLoadIdentity() + #glLoadIdentity() # DRAW transition_time = self.image_time - self.display_duration - complete = self.transition.draw(self.texture_current, self.texture_next, self.window_width, self.window_height, delta_time, transition_time, self.transition_duration) - - glClearColor(0.0, 0.0, 0.0, 1.0) # Set the background color to black + complete = self.renderer.draw_transition(self.texture_current, self.texture_next, self.window_width, self.window_height, delta_time, transition_time, self.transition_duration) glutSwapBuffers() @@ -86,6 +88,10 @@ class PixDisplay: self.image_time = 0 self.current_texture_index = self.next_texture_index + def timer(self, value): + glutPostRedisplay() + glutTimerFunc(self.frame_time, self.timer, 0) # Schedule next frame + # Initialization and main loop def main(self): # Initialize the window @@ -94,6 +100,8 @@ class PixDisplay: glutCreateWindow("Image Viewer with Fade Transition") glEnable(GL_TEXTURE_2D) + self.renderer = ImageRenderer() + self.renderer.set_transition(TransitionMix) self.image_time = 0 self.start_time = time() self.last_time = time() @@ -104,7 +112,12 @@ class PixDisplay: glOrtho(-1, 1, -1, 1, -1, 1) glMatrixMode(GL_MODELVIEW) + # Enable alpha blending + glEnable(GL_BLEND) + glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA) + + # Run display glutDisplayFunc(self.display) - glutIdleFunc(self.display) + glutTimerFunc(self.frame_time, self.timer, 0) glutMainLoop() -- cgit v1.2.3 From b823139687f3c55dd3695e11a0cf8f9693524e9b Mon Sep 17 00:00:00 2001 From: Tim Keller Date: Sat, 10 May 2025 15:26:01 -0500 Subject: max framerate setter getter --- window.py | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/window.py b/window.py index 4c51bd1..e3c9fa2 100644 --- a/window.py +++ b/window.py @@ -17,13 +17,20 @@ class PixDisplay: self.renderer = None self.window_width = 0 self.window_height = 0 - # TODO - self.max_framerate = 60 - self.frame_time = int(1000 / self.max_framerate) # In ms + self.max_framerate = 30 self.display_duration = 2.0 self.transition_duration = 0.5 + @property + def max_framerate(self): + return self._max_fps + + @max_framerate.setter + def max_framerate(self, max_fps): + self._max_fps = max_fps # This is just for the getter since otherwise e.g. int(1000/int(1000/60)) would round to 62 + self.frame_time = int(1000 / 60) # In ms + @property def next_texture_index(self): return (self.current_texture_index + 1) % len(self.textures) @@ -44,8 +51,6 @@ class PixDisplay: if not self.textures: glClearColor(0.0, 0.0, 0.0, 1.0) # Set the background color to black glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT) - #glLoadIdentity() - glutSwapBuffers() return @@ -65,7 +70,6 @@ class PixDisplay: glClearColor(0.0, 0.0, 0.0, 1.0) # Set the background color to black glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT) - #glLoadIdentity() self.renderer.draw_static(self.texture_current, self.window_width, self.window_height, 1) @@ -76,7 +80,6 @@ class PixDisplay: glClearColor(0.0, 0.0, 0.0, 1.0) # Set the background color to black glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT) - #glLoadIdentity() # DRAW transition_time = self.image_time - self.display_duration -- cgit v1.2.3 From 3aed05a6cb265e4f60a17f87eb368fb33c93a562 Mon Sep 17 00:00:00 2001 From: Tim Keller Date: Sat, 10 May 2025 16:26:33 -0500 Subject: cleanup some --- renderer.py | 49 ++++++++++++++++++++++++++++++++++--------------- texture.py | 4 ++-- window.py | 51 +++++++++++++++++++++++---------------------------- 3 files changed, 59 insertions(+), 45 deletions(-) diff --git a/renderer.py b/renderer.py index 92d0686..01c9f3f 100644 --- a/renderer.py +++ b/renderer.py @@ -8,7 +8,7 @@ class ImageRenderer: def __init__(self): # Setup shader and quad self.shader = self._init_shader() - self._init_quad() + self.vao = self._init_quad() # Get uniform locations from the shader self.uTransform = glGetUniformLocation(self.shader, "uTransform") @@ -21,8 +21,11 @@ class ImageRenderer: # Setup transition self.transition = None - def set_transition(self, transition): - self.transition = transition(self) + # State helper + self._state = None + + def set_transition(self, transition_cls): + self.transition = transition_cls(self) @staticmethod def compile_shader(source, shader_type): @@ -33,7 +36,8 @@ class ImageRenderer: raise RuntimeError(glGetShaderInfoLog(shader).decode()) # Raise error with log if failed return shader - def _init_shader(self): + @staticmethod + def _init_shader(): vertex_src = """ #version 330 core layout (location = 0) in vec2 aPos; // Vertex position @@ -68,7 +72,8 @@ class ImageRenderer: glLinkProgram(prog) return prog - def _init_quad(self): + @staticmethod + def _init_quad(): # Define a full-screen quad with positions and texture coordinates quad = np.array([ -1, -1, 0, 0, # Bottom-left @@ -78,9 +83,9 @@ class ImageRenderer: ], dtype=np.float32) # Create and bind a Vertex Array Object - self.vao = glGenVertexArrays(1) + vao = glGenVertexArrays(1) vbo = glGenBuffers(1) - glBindVertexArray(self.vao) + glBindVertexArray(vao) glBindBuffer(GL_ARRAY_BUFFER, vbo) glBufferData(GL_ARRAY_BUFFER, quad.nbytes, quad, GL_STATIC_DRAW) @@ -91,8 +96,9 @@ class ImageRenderer: # Setup vertex attributes: texture coordinates (location = 1) glVertexAttribPointer(1, 2, GL_FLOAT, GL_FALSE, 16, ctypes.c_void_p(8)) glEnableVertexAttribArray(1) + return vao - def draw_static(self, tex, win_w, win_h, alpha): + def draw_image(self, tex, win_w, win_h, alpha): glUseProgram(self.shader) glBindVertexArray(self.vao) glBindTexture(GL_TEXTURE_2D, tex.id) @@ -124,9 +130,25 @@ class ImageRenderer: # Draw the textured quad glDrawArrays(GL_TRIANGLE_STRIP, 0, 4) + def draw_static(self, tex, win_w, win_h, alpha): + # Set the background color to black + glClearColor(0.0, 0.0, 0.0, 1.0) + glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT) + + self.draw_image(tex, win_w, win_h, alpha) + + glutSwapBuffers() + def draw_transition(self, tex_start, tex_end, win_w, win_h, delta_time, transition_time, transition_duration): assert self.transition, "No transition has been set" - return self.transition.draw(tex_start, tex_end, win_w, win_h, delta_time, transition_time, transition_duration) + + # Set the background color to black + glClearColor(0.0, 0.0, 0.0, 1.0) + glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT) + + self.transition.draw(tex_start, tex_end, win_w, win_h, delta_time, transition_time, transition_duration) + + glutSwapBuffers() class Transition(Protocol): @@ -144,9 +166,6 @@ class TransitionMix(Transition): if alpha > 1.0: alpha = 1.0 - # Draw the first image - self.renderer.draw_static(tex_start, win_w, win_h, 1 - alpha) # TODO instead of decreasing alpha, draw transparent letterboxes - # Draw the second image (with transparency) - self.renderer.draw_static(tex_end, win_w, win_h, alpha) - - return alpha >= 1.0 # Complete + # Draw the images on top of one another + self.renderer.draw_image(tex_start, win_w, win_h, 1 - alpha) # TODO instead of decreasing alpha, draw transparent letterboxes + self.renderer.draw_image(tex_end, win_w, win_h, alpha) diff --git a/texture.py b/texture.py index 73b7a84..ebd1a49 100644 --- a/texture.py +++ b/texture.py @@ -7,8 +7,8 @@ class ImageTexture: def __init__(self, image_source, exif=None): self.id = None img = Image.open(image_source) - self.exif = exif or ImageTexture.get_exif(img) - img = ImageTexture.handle_orientation(img, self.exif) + self.exif = exif or self.get_exif(img) + img = self.handle_orientation(img, self.exif) img = img.convert("RGBA") # Ensure the image is in RGBA mode self.width = img.width self.height = img.height diff --git a/window.py b/window.py index e3c9fa2..ef9e9ee 100644 --- a/window.py +++ b/window.py @@ -15,8 +15,8 @@ class PixDisplay: self.textures = [] self.current_texture_index = 0 self.renderer = None - self.window_width = 0 - self.window_height = 0 + self.win_w = 0 + self.win_h = 0 self.max_framerate = 30 self.display_duration = 2.0 @@ -31,14 +31,17 @@ class PixDisplay: self._max_fps = max_fps # This is just for the getter since otherwise e.g. int(1000/int(1000/60)) would round to 62 self.frame_time = int(1000 / 60) # In ms + @property + def prev_texture_index(self): return self.current_texture_index - 1 + @property def next_texture_index(self): return (self.current_texture_index + 1) % len(self.textures) @property - def texture_current(self): return self.textures[self.current_texture_index] + def tex(self): return self.textures[self.current_texture_index] @property - def texture_next(self): return self.textures[self.next_texture_index] + def tex_next(self): return self.textures[self.next_texture_index] # Main display function def display(self): @@ -48,46 +51,38 @@ class PixDisplay: delta_time = current_time - self.last_time self.last_time = current_time + # Draw black window if no textures are available if not self.textures: - glClearColor(0.0, 0.0, 0.0, 1.0) # Set the background color to black + glClearColor(0.0, 0.0, 0.0, 1.0) glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT) glutSwapBuffers() return # Ensure textures are initialized - self.texture_current.gl_init() - self.texture_next.gl_init() + self.tex.gl_init() + self.tex_next.gl_init() # Progress image time self.image_time += delta_time # Get window size - window_width, window_height = glutGet(GLUT_WINDOW_WIDTH), glutGet(GLUT_WINDOW_HEIGHT) - - if self.image_time < self.display_duration: - if window_width != self.window_width or window_height != self.window_height: - self.window_width, self.window_height = window_width, window_height - - glClearColor(0.0, 0.0, 0.0, 1.0) # Set the background color to black - glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT) - - self.renderer.draw_static(self.texture_current, self.window_width, self.window_height, 1) - - glutSwapBuffers() + old_win_w, old_win_h = self.win_w, self.win_h + self.win_w, self.win_h = glutGet(GLUT_WINDOW_WIDTH), glutGet(GLUT_WINDOW_HEIGHT) + + # Draw static image except during a transition + if self.image_time <= self.display_duration: + # Avoid redraw unless window size has changed + if self.win_w != old_win_w or self.win_h != old_win_h: + self.renderer.draw_static(self.tex, self.win_w, self.win_h, 1) return - self.window_width, self.window_height = window_width, window_height - - glClearColor(0.0, 0.0, 0.0, 1.0) # Set the background color to black - glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT) - - # DRAW + # Draw transition transition_time = self.image_time - self.display_duration - complete = self.renderer.draw_transition(self.texture_current, self.texture_next, self.window_width, self.window_height, delta_time, transition_time, self.transition_duration) - glutSwapBuffers() + # Draw frame of transition, function will return True if the transition is complete + self.renderer.draw_transition(self.tex, self.tex_next, self.win_w, self.win_h, delta_time, transition_time, self.transition_duration) - if complete: + if transition_time >= self.transition_duration: self.image_time = 0 self.current_texture_index = self.next_texture_index -- cgit v1.2.3 From c45551035d4f2ef65f8de0472bfda4018636f309 Mon Sep 17 00:00:00 2001 From: Tim Keller Date: Sat, 10 May 2025 18:21:27 -0500 Subject: handle ctrl+c --- pix.py | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/pix.py b/pix.py index 1e00fcf..6e53b8d 100644 --- a/pix.py +++ b/pix.py @@ -1,12 +1,26 @@ import sys +import signal from threading import Thread +from OpenGL.GLUT import glutLeaveMainLoop from window import PixDisplay from immich import ImmichConnector + +def handle_sigint(sig, frame): + try: + glutLeaveMainLoop() + sys.exit(0) + except: + pass + finally: + print("Exiting on Ctrl+C") + + if __name__ == "__main__": immichConnector = ImmichConnector("http://192.168.1.13", "m5nqOoBc4uhAba21gZdCP3z8D3JT4GPxDXL2psd52EA") pd = PixDisplay() t1 = Thread(target=immichConnector.idle, daemon=True, args=(pd,)) t1.start() - pd.main() + signal.signal(signal.SIGINT, handle_sigint) + pd.main(sys.argv) -- cgit v1.2.3 From 9a785dfddad4215672c41396b8554477c18b4066 Mon Sep 17 00:00:00 2001 From: Tim Keller Date: Sat, 10 May 2025 18:22:40 -0500 Subject: transition using arrow keys + skip function --- renderer.py | 11 ++++----- window.py | 77 +++++++++++++++++++++++++++++++++++++++---------------------- 2 files changed, 53 insertions(+), 35 deletions(-) diff --git a/renderer.py b/renderer.py index 01c9f3f..909ad2b 100644 --- a/renderer.py +++ b/renderer.py @@ -21,9 +21,6 @@ class ImageRenderer: # Setup transition self.transition = None - # State helper - self._state = None - def set_transition(self, transition_cls): self.transition = transition_cls(self) @@ -139,14 +136,14 @@ class ImageRenderer: glutSwapBuffers() - def draw_transition(self, tex_start, tex_end, win_w, win_h, delta_time, transition_time, transition_duration): + def draw_transition(self, tex_start, tex_end, win_w, win_h, delta_time, transition_time, transition_duration, reversed): assert self.transition, "No transition has been set" # Set the background color to black glClearColor(0.0, 0.0, 0.0, 1.0) glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT) - self.transition.draw(tex_start, tex_end, win_w, win_h, delta_time, transition_time, transition_duration) + self.transition.draw(tex_start, tex_end, win_w, win_h, delta_time, transition_time, transition_duration, reversed) glutSwapBuffers() @@ -155,12 +152,12 @@ class Transition(Protocol): def __init__(self, renderer): self.renderer = renderer - def draw(self, tex_start, tex_end, win_w, win_h, delta_time, transition_time, transition_duration): + def draw(self, tex_start, tex_end, win_w, win_h, delta_time, transition_time, transition_duration, reversed): pass class TransitionMix(Transition): - def draw(self, tex_start, tex_end, win_w, win_h, delta_time, transition_time, transition_duration): + def draw(self, tex_start, tex_end, win_w, win_h, delta_time, transition_time, transition_duration, reversed): # Update alpha value for fade effect alpha = transition_time / transition_duration if alpha > 1.0: diff --git a/window.py b/window.py index ef9e9ee..8b34989 100644 --- a/window.py +++ b/window.py @@ -19,8 +19,13 @@ class PixDisplay: self.win_h = 0 self.max_framerate = 30 - self.display_duration = 2.0 + self.image_duration = 2.0 self.transition_duration = 0.5 + self.auto_transition = True + self.transition_reverse = False + + self.tex = None + self.text_next = None @property def max_framerate(self): @@ -31,17 +36,19 @@ class PixDisplay: self._max_fps = max_fps # This is just for the getter since otherwise e.g. int(1000/int(1000/60)) would round to 62 self.frame_time = int(1000 / 60) # In ms - @property - def prev_texture_index(self): return self.current_texture_index - 1 + def increment_texture_index(self, increment): + if len(self.textures) < 2: + return - @property - def next_texture_index(self): return (self.current_texture_index + 1) % len(self.textures) + self.transition_reverse = increment < 0 - @property - def tex(self): return self.textures[self.current_texture_index] + self.tex_prev = self.textures[self.current_texture_index] + self.current_texture_index = (self.current_texture_index + increment) % len(self.textures) + self.tex = self.textures[self.current_texture_index] - @property - def tex_next(self): return self.textures[self.next_texture_index] + # Ensure textures are initialized + self.tex_prev.gl_init() + self.tex.gl_init() # Main display function def display(self): @@ -51,16 +58,14 @@ class PixDisplay: delta_time = current_time - self.last_time self.last_time = current_time - # Draw black window if no textures are available - if not self.textures: - glClearColor(0.0, 0.0, 0.0, 1.0) - glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT) - glutSwapBuffers() - return - - # Ensure textures are initialized - self.tex.gl_init() - self.tex_next.gl_init() + if not self.tex: + self.increment_texture_index(0) + # Draw black window if no textures are available + if not self.tex: + glClearColor(0.0, 0.0, 0.0, 1.0) + glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT) + glutSwapBuffers() + return # Progress image time self.image_time += delta_time @@ -70,30 +75,45 @@ class PixDisplay: self.win_w, self.win_h = glutGet(GLUT_WINDOW_WIDTH), glutGet(GLUT_WINDOW_HEIGHT) # Draw static image except during a transition - if self.image_time <= self.display_duration: + if self.image_time < self.image_duration: # Avoid redraw unless window size has changed if self.win_w != old_win_w or self.win_h != old_win_h: - self.renderer.draw_static(self.tex, self.win_w, self.win_h, 1) + self.renderer.draw_static(self.tex, self.win_w, self.win_h, 1.0) return - # Draw transition - transition_time = self.image_time - self.display_duration + # Start drawing transition once image_time >= image_duration + if self.auto_transition: + self.increment_texture_index(1) + self.auto_transition = False - # Draw frame of transition, function will return True if the transition is complete - self.renderer.draw_transition(self.tex, self.tex_next, self.win_w, self.win_h, delta_time, transition_time, self.transition_duration) + transition_time = self.image_time - self.image_duration + + self.renderer.draw_transition(self.tex_prev, self.tex, self.win_w, self.win_h, delta_time, transition_time, self.transition_duration, self.transition_reverse) if transition_time >= self.transition_duration: self.image_time = 0 - self.current_texture_index = self.next_texture_index + self.auto_transition = True + # Limit framerate def timer(self, value): glutPostRedisplay() glutTimerFunc(self.frame_time, self.timer, 0) # Schedule next frame + def skip(self, increment): + self.auto_transition = False + self.increment_texture_index(increment) + self.image_time = self.image_duration + + def handle_special_key(self, key, x, y): + if key == GLUT_KEY_LEFT: + self.skip(-1) + elif key == GLUT_KEY_RIGHT: + self.skip(1) + # Initialization and main loop - def main(self): + def main(self, glut_args): # Initialize the window - glutInit(sys.argv) + glutInit(glut_args) glutInitDisplayMode(GLUT_DOUBLE | GLUT_RGB) glutCreateWindow("Image Viewer with Fade Transition") glEnable(GL_TEXTURE_2D) @@ -117,5 +137,6 @@ class PixDisplay: # Run display glutDisplayFunc(self.display) glutTimerFunc(self.frame_time, self.timer, 0) + glutSpecialFunc(self.handle_special_key) glutMainLoop() -- cgit v1.2.3 From fc570fc38b450b90a2c8da05e5619f19ba8e983d Mon Sep 17 00:00:00 2001 From: Tim Keller Date: Sat, 10 May 2025 18:31:02 -0500 Subject: add force redraw flag and redraw whenever visibility is restored in case framebuffer is destroyed --- window.py | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/window.py b/window.py index 8b34989..8e04225 100644 --- a/window.py +++ b/window.py @@ -27,6 +27,8 @@ class PixDisplay: self.tex = None self.text_next = None + self._force_redraw = False + @property def max_framerate(self): return self._max_fps @@ -76,9 +78,10 @@ class PixDisplay: # Draw static image except during a transition if self.image_time < self.image_duration: - # Avoid redraw unless window size has changed - if self.win_w != old_win_w or self.win_h != old_win_h: + # Avoid unforced-redraw unless window size has changed + if self._force_redraw or self.win_w != old_win_w or self.win_h != old_win_h: self.renderer.draw_static(self.tex, self.win_w, self.win_h, 1.0) + self._force_redraw = False return # Start drawing transition once image_time >= image_duration @@ -110,6 +113,11 @@ class PixDisplay: elif key == GLUT_KEY_RIGHT: self.skip(1) + def handle_visibility_change(self, state): + if state == GLUT_VISIBLE: + self._force_redraw = True + glutPostRedisplay() + # Initialization and main loop def main(self, glut_args): # Initialize the window @@ -137,6 +145,7 @@ class PixDisplay: # Run display glutDisplayFunc(self.display) glutTimerFunc(self.frame_time, self.timer, 0) + glutVisibilityFunc(self.handle_visibility_change) # Redraw in case framebuffer gets destroyed when window is obscured glutSpecialFunc(self.handle_special_key) glutMainLoop() -- cgit v1.2.3 From e7036d21d5e5c87702724283f55a77d07344f4fe Mon Sep 17 00:00:00 2001 From: Tim Keller Date: Sat, 10 May 2025 19:47:31 -0500 Subject: add flaskapi --- flaskapi.py | 24 ++++++++++++++++++++++++ pix.py | 12 ++++++++++-- window.py | 19 ++++++++++++++----- 3 files changed, 48 insertions(+), 7 deletions(-) create mode 100644 flaskapi.py diff --git a/flaskapi.py b/flaskapi.py new file mode 100644 index 0000000..4b1a259 --- /dev/null +++ b/flaskapi.py @@ -0,0 +1,24 @@ +from flask import Flask, Blueprint, request + +app = Flask(__name__) + +@app.route("/") +def home(): + return "Flask is running!" + +api = Blueprint("api", __name__) + +@api.route("/seek") +def seek(): + pd = app.config["pix_display"] + + increment = request.args.get("increment", default=1, type=int) + pd.queue.put(lambda: pd.seek(increment)) + while not pd.queue.empty(): + pass + return { + "imageTime": pd.image_time, + "imageIndex": pd.current_texture_index, + } + +app.register_blueprint(api, url_prefix="/api") diff --git a/pix.py b/pix.py index 6e53b8d..aa1495f 100644 --- a/pix.py +++ b/pix.py @@ -5,6 +5,7 @@ from OpenGL.GLUT import glutLeaveMainLoop from window import PixDisplay from immich import ImmichConnector +from flaskapi import app def handle_sigint(sig, frame): @@ -18,9 +19,16 @@ def handle_sigint(sig, frame): if __name__ == "__main__": - immichConnector = ImmichConnector("http://192.168.1.13", "m5nqOoBc4uhAba21gZdCP3z8D3JT4GPxDXL2psd52EA") pd = PixDisplay() - t1 = Thread(target=immichConnector.idle, daemon=True, args=(pd,)) + immich_connector = ImmichConnector("http://192.168.1.13", "m5nqOoBc4uhAba21gZdCP3z8D3JT4GPxDXL2psd52EA") + + t1 = Thread(target=immich_connector.idle, daemon=True, args=(pd,)) t1.start() + + app.config["pix_display"] = pd + app.config["immich_connector"] = immich_connector + flask_thread = Thread(target=app.run, daemon=True, kwargs={ "port": 5000 }) + flask_thread.start() + signal.signal(signal.SIGINT, handle_sigint) pd.main(sys.argv) diff --git a/window.py b/window.py index 8e04225..24320b9 100644 --- a/window.py +++ b/window.py @@ -2,6 +2,7 @@ from OpenGL.GL import * from OpenGL.GLUT import * from OpenGL.GLU import * from time import time +from queue import Queue from renderer import ImageRenderer, TransitionMix from texture import ImageTexture @@ -12,6 +13,7 @@ class PixDisplay: self.last_time = 0 self.start_time = 0 self.image_time = 0 + self.paused = False self.textures = [] self.current_texture_index = 0 self.renderer = None @@ -24,10 +26,11 @@ class PixDisplay: self.auto_transition = True self.transition_reverse = False + self.text_prev = None self.tex = None - self.text_next = None self._force_redraw = False + self.queue = Queue() @property def max_framerate(self): @@ -69,8 +72,14 @@ class PixDisplay: glutSwapBuffers() return + # Run queue events + while not self.queue.empty(): + f = self.queue.get() # Get the task and its data + f() + # Progress image time - self.image_time += delta_time + if not self.paused: + self.image_time += delta_time # Get window size old_win_w, old_win_h = self.win_w, self.win_h @@ -102,16 +111,16 @@ class PixDisplay: glutPostRedisplay() glutTimerFunc(self.frame_time, self.timer, 0) # Schedule next frame - def skip(self, increment): + def seek(self, increment): self.auto_transition = False self.increment_texture_index(increment) self.image_time = self.image_duration def handle_special_key(self, key, x, y): if key == GLUT_KEY_LEFT: - self.skip(-1) + self.seek(-1) elif key == GLUT_KEY_RIGHT: - self.skip(1) + self.seek(1) def handle_visibility_change(self, state): if state == GLUT_VISIBLE: -- cgit v1.2.3 From 8afd27d113d20f924df73456374153397039e1ba Mon Sep 17 00:00:00 2001 From: Tim Keller Date: Sat, 10 May 2025 20:01:04 -0500 Subject: add module for immich frame client project and endpoints (merge later) --- .gitmodules | 3 +++ flaskapi.py | 9 ++++++--- static | 1 + 3 files changed, 10 insertions(+), 3 deletions(-) create mode 100644 .gitmodules create mode 160000 static diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..b8e8233 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "static"] + path = static + url = git@publicgit:immich-frame diff --git a/flaskapi.py b/flaskapi.py index 4b1a259..29cea7e 100644 --- a/flaskapi.py +++ b/flaskapi.py @@ -1,10 +1,13 @@ -from flask import Flask, Blueprint, request +from flask import Flask, Blueprint, request, send_from_directory -app = Flask(__name__) +app = Flask(__name__, static_folder="static/dist", static_url_path="/") @app.route("/") +@app.route("/slideshow") +@app.route("/albums") +@app.route("/settings") def home(): - return "Flask is running!" + return send_from_directory("static/public", "index.html") api = Blueprint("api", __name__) diff --git a/static b/static new file mode 160000 index 0000000..f86d11c --- /dev/null +++ b/static @@ -0,0 +1 @@ +Subproject commit f86d11c3ce1f04ee89da235d78447aed6d6d7130 -- cgit v1.2.3 From 31f1940ec8c4aba6a0c21b20eff9657d5d11cf80 Mon Sep 17 00:00:00 2001 From: Tim Keller Date: Sun, 11 May 2025 00:20:08 -0500 Subject: albums thumbnails endpoint and albums endpoint --- flaskapi.py | 22 +++++++++++++++++++++- immich.py | 20 ++++++++++++++++---- 2 files changed, 37 insertions(+), 5 deletions(-) diff --git a/flaskapi.py b/flaskapi.py index 29cea7e..0791016 100644 --- a/flaskapi.py +++ b/flaskapi.py @@ -1,4 +1,5 @@ -from flask import Flask, Blueprint, request, send_from_directory +from flask import Flask, Blueprint, request, send_from_directory, send_file, abort +from flask_cors import CORS app = Flask(__name__, static_folder="static/dist", static_url_path="/") @@ -10,6 +11,7 @@ def home(): return send_from_directory("static/public", "index.html") api = Blueprint("api", __name__) +CORS(api, origins="*") # For debugging TODO remove later @api.route("/seek") def seek(): @@ -24,4 +26,22 @@ def seek(): "imageIndex": pd.current_texture_index, } +@api.route("/albums/get") +def get_albums(): + ic = app.config["immich_connector"] + keys = [ "albumName", "albumThumbnailAssetId", "id", "startDate", "endDate", "assetCount", "shared", ] + return [{ + key: album[key] for key in keys + } for album in ic.load_all_albums() ] + +@api.route("/albums/thumb/") +def get_album_thumb(key): + # TODO ensure getting actual album thumb + ic = app.config["immich_connector"] + image_data, mimetype = ic.load_image(key, size="thumbnail") + if image_data is None: + abort(400) + return send_file(image_data, mimetype=mimetype) + + app.register_blueprint(api, url_prefix="/api") diff --git a/immich.py b/immich.py index 1f1ccb8..63e6f63 100644 --- a/immich.py +++ b/immich.py @@ -12,6 +12,13 @@ class ImmichConnector: def _request(self, endpoint): return requests.get(f"{self.server_url}/api/{endpoint}", headers={ "x-api-key": self.api_key }) + def load_all_albums(self): + response = self._request("albums") + if response.status_code != 200: return + + data = response.json() + return data + def load_album_assets(self, key): response = self._request(f"albums/{key}") if response.status_code != 200: return @@ -19,15 +26,20 @@ class ImmichConnector: data = response.json() return data["assets"] - def load_image(self, pd, key, exif=None): - response = self._request(f"assets/{key}/thumbnail?size=fullsize") - if response.status_code != 200: return + def load_image(self, key, size="preview"): + response = self._request(f"assets/{key}/thumbnail?size={size}") + if response.status_code != 200: return None, None image_data = BytesIO(response.content) + mimetype = response.headers.get("Content-Type") + return image_data, mimetype + + def load_texture(self, pd, key, exif=None, size="preview"): + image_data, _ = self.load_image(key, size) it = ImageTexture(image_data, exif) pd.textures.append(it) print(f"Loaded image {key}") def idle(self, pd): for asset in self.load_album_assets("bac029a5-972b-4519-bce0-a0d74add3969"): - self.load_image(pd, asset["id"], asset["exifInfo"]) + self.load_texture(pd, asset["id"], asset["exifInfo"]) -- cgit v1.2.3 From e1a6fc09afc088dcb67263ed5923f5be41c32c31 Mon Sep 17 00:00:00 2001 From: Tim Keller Date: Sun, 25 May 2025 21:38:37 -0500 Subject: use lazy caching texture list to limit number of images loaded at one time --- immich.py | 22 ++++++---- lazycachelist.py | 126 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ pix.py | 7 +++- static | 2 +- texture.py | 26 +++++++++++- window.py | 17 ++++---- 6 files changed, 178 insertions(+), 22 deletions(-) create mode 100644 lazycachelist.py diff --git a/immich.py b/immich.py index 63e6f63..e73b34e 100644 --- a/immich.py +++ b/immich.py @@ -1,5 +1,6 @@ import requests from io import BytesIO +from queue import Queue from texture import ImageTexture @@ -8,6 +9,7 @@ class ImmichConnector: def __init__(self, server_url, api_key): self.server_url = server_url.removesuffix("/") self.api_key = api_key + self.texture_load_queue = Queue() def _request(self, endpoint): return requests.get(f"{self.server_url}/api/{endpoint}", headers={ "x-api-key": self.api_key }) @@ -34,12 +36,14 @@ class ImmichConnector: mimetype = response.headers.get("Content-Type") return image_data, mimetype - def load_texture(self, pd, key, exif=None, size="preview"): - image_data, _ = self.load_image(key, size) - it = ImageTexture(image_data, exif) - pd.textures.append(it) - print(f"Loaded image {key}") - - def idle(self, pd): - for asset in self.load_album_assets("bac029a5-972b-4519-bce0-a0d74add3969"): - self.load_texture(pd, asset["id"], asset["exifInfo"]) + def load_texture_async(self, texture_list, image_texture): + self.texture_load_queue.put((texture_list, image_texture)) + + def idle(self): + size = "preview" # TODO + while True: + texture_list, image_texture = self.texture_load_queue.get() + if not texture_list.index_in_cache_range(image_texture.asset_index): + continue # Texture was never loaded so it doesn't need to be free'd + image_data, _ = self.load_image(image_texture.asset_key, size) + image_texture.initialize(image_data) diff --git a/lazycachelist.py b/lazycachelist.py new file mode 100644 index 0000000..0c37ba3 --- /dev/null +++ b/lazycachelist.py @@ -0,0 +1,126 @@ +from dataclasses import dataclass + +from texture import ImageTextureImmichAsset + +@dataclass +class Album: + id: str + range_start: int + range_end: int + assets_list: list[str] = None + + @property + def assets_count(self): + return end - start + + +class LazyCachingTextureList(): + def __init__(self, immich_connector, album_ids, max_cache_items=100): + self.immich_connector = immich_connector + assert max_cache_items >= 20, "Minimum cache items is 20" # Double small radius + + # Ring buffer + self.cache_length = max_cache_items + self.cache = [None] * max_cache_items + self.radius_small = 10 + self.radius_large = int(max_cache_items / 2) + self.cache_items_behind = 0 + self.cache_items_ahead = 0 + self.cache_index = 0 + self.asset_index = 0 + self.asset_count = 0 + + self.cached_items = 0 + + self.album_keys = album_ids + self.albums = self._get_albums() + + def index_in_cache_range(self, index): + index_range_low = (self.asset_index - self.cache_items_behind) % self.asset_count + index_range_high = (self.asset_index + self.cache_items_ahead ) % self.asset_count + if index_range_low > index_range_high: + return index_range_low <= index or index <= index_range_high + return index_range_low <= index <= index_range_high + + def _get_albums(self): + albums = [] + self.asset_count = i = 0 + albums_info = self.immich_connector.load_all_albums() + for album_info in albums_info: + id = album_info["id"] + if id not in self.album_keys: + continue + asset_count = album_info["assetCount"] + albums.append(Album(id, i, i + asset_count)) + i += asset_count + 1 + self.asset_count += asset_count + #self.asset_count = sum(( album.assets_count for album in self.albums )) + return albums + + def _get_album_asset_by_index(self, asset_index): + if asset_index < 0: + asset_index = asset_count - asset_index + for album in self.albums: + if album.range_start <= asset_index <= album.range_end: + if album.assets_list is None: + album.assets_list = self.immich_connector.load_album_assets(album.id) + return album.assets_list[asset_index - album.range_start] + raise IndexError("Index out of bounds") + + def _fill_cache(self, current_radius, final_radius, step): + if current_radius >= final_radius: + return current_radius + + for i in range(current_radius * step, final_radius * step, step): + cache_index = (self.cache_index + i) % self.cache_length + asset_index = (self.asset_index + i) % self.asset_count + if self.cache[cache_index]: + self.cache[cache_index].free() # Since this is a ring buffer, textures outside of range can just get free'd here + asset = self._get_album_asset_by_index(asset_index) + tex = ImageTextureImmichAsset(asset, asset_index) + self.immich_connector.load_texture_async(self, tex) + self.cache[cache_index] = tex + self.cached_items += 1 + + return max(current_radius, final_radius) + + def _update_cache_get_item(self, asset_index): + prev_asset_index = self.asset_index + self.asset_index = asset_index % self.asset_count + # Movement is the distance between the previous and current index in the assets list + # Since the list wraps around, fastest method is just to get the abs min of the 3 cases + movement = min( + asset_index - prev_asset_index, # No list wrap + asset_index - self.asset_count, # Wrap backwards (0 -> -1) + self.asset_count - prev_asset_index, # Wrap forwards (-1 -> 0) + key=abs) + self.cache_index = (self.cache_index + movement) % self.cache_length + + ahead = max(0, self.cache_items_ahead - movement) + behind = max(0, self.cache_items_behind + movement) + + if ahead + behind > self.cache_length: + if movement < 0: + ahead += movement + else: + behind -= movement + + #print("AHEAD/BEHIND/CACHE_I/ASSET_I/MOVEMENT:", ahead, behind, self.cache_index, self.asset_index, movement) + # TODO if ahead is 0 then clear queue + + ahead = self._fill_cache(ahead, self.radius_small, +1) # Fill small radius ahead of cache_index + behind = self._fill_cache(behind, self.radius_small, -1) # Fill small radius behind cache_index + ahead = self._fill_cache(ahead, self.radius_large, +1) # Fill large radius ahead of cache_index + + self.cache_items_ahead = ahead + self.cache_items_behind = behind + return self.cache[self.cache_index] + + def __len__(self): + return self.asset_count + + def __getitem__(self, index): + i = index % self.asset_count + if abs(index) > i: + raise IndexError("Index out of bounds") + return self._update_cache_get_item(index) diff --git a/pix.py b/pix.py index aa1495f..4064892 100644 --- a/pix.py +++ b/pix.py @@ -3,6 +3,7 @@ import signal from threading import Thread from OpenGL.GLUT import glutLeaveMainLoop +from lazycachelist import LazyCachingTextureList from window import PixDisplay from immich import ImmichConnector from flaskapi import app @@ -19,10 +20,12 @@ def handle_sigint(sig, frame): if __name__ == "__main__": - pd = PixDisplay() immich_connector = ImmichConnector("http://192.168.1.13", "m5nqOoBc4uhAba21gZdCP3z8D3JT4GPxDXL2psd52EA") + album_keys = [ "38617851-6b57-44f1-b5f7-82577606afc4" ] + lazy_texture_list = LazyCachingTextureList(immich_connector, album_keys, 30) + pd = PixDisplay(lazy_texture_list) - t1 = Thread(target=immich_connector.idle, daemon=True, args=(pd,)) + t1 = Thread(target=immich_connector.idle, daemon=True) t1.start() app.config["pix_display"] = pd diff --git a/static b/static index f86d11c..51be71d 160000 --- a/static +++ b/static @@ -1 +1 @@ -Subproject commit f86d11c3ce1f04ee89da235d78447aed6d6d7130 +Subproject commit 51be71d8943f6e5e0c4b28358f227860c73d53a7 diff --git a/texture.py b/texture.py index ebd1a49..3a56b5f 100644 --- a/texture.py +++ b/texture.py @@ -3,6 +3,7 @@ from OpenGL.GLUT import * from OpenGL.GLU import * from PIL import Image, ExifTags + class ImageTexture: def __init__(self, image_source, exif=None): self.id = None @@ -13,6 +14,7 @@ class ImageTexture: self.width = img.width self.height = img.height self._img_data = img.tobytes("raw", "RGBA", 0, -1) # Convert image data to bytes + self.initialized = True def gl_init(self): if self.id: @@ -26,7 +28,14 @@ class ImageTexture: glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR) del self._img_data # No longer needed, clear mem - print(f"Loaded texture {self.id}") + #print(f"Loaded texture {self.id}") + + def free(self): + if self.id is None: + del self._img_data + else: + glBindTexture(GL_TEXTURE_2D, 0) # Unbind + glDeleteTextures([self.id]) @staticmethod def get_exif(img): @@ -41,3 +50,18 @@ class ImageTexture: if orientation == 8: return img.rotate( 90, expand=True) return img + + +class ImageTextureImmichAsset(ImageTexture): + def __init__(self, asset, asset_index): + self.initialized = False + self.asset_index = asset_index + self.asset_key = asset["id"] + self.exif = asset.get("exif", None) + + # So that free can work + self.id = None + self._img_data = None + + def initialize(self, image_source): + super().__init__(image_source, self.exif) diff --git a/window.py b/window.py index 24320b9..1523f85 100644 --- a/window.py +++ b/window.py @@ -5,16 +5,15 @@ from time import time from queue import Queue from renderer import ImageRenderer, TransitionMix -from texture import ImageTexture class PixDisplay: - def __init__(self): + def __init__(self, textures): self.last_time = 0 self.start_time = 0 self.image_time = 0 self.paused = False - self.textures = [] + self.textures = textures self.current_texture_index = 0 self.renderer = None self.win_w = 0 @@ -42,16 +41,16 @@ class PixDisplay: self.frame_time = int(1000 / 60) # In ms def increment_texture_index(self, increment): - if len(self.textures) < 2: - return - self.transition_reverse = increment < 0 self.tex_prev = self.textures[self.current_texture_index] self.current_texture_index = (self.current_texture_index + increment) % len(self.textures) self.tex = self.textures[self.current_texture_index] - # Ensure textures are initialized + if not self.tex.initialized or not self.tex_prev.initialized: + return + + # Ensure textures are initialized for opengl self.tex_prev.gl_init() self.tex.gl_init() @@ -63,10 +62,10 @@ class PixDisplay: delta_time = current_time - self.last_time self.last_time = current_time - if not self.tex: + if not self.tex or not self.tex.initialized or not self.tex.id: self.increment_texture_index(0) # Draw black window if no textures are available - if not self.tex: + if not self.tex.id: glClearColor(0.0, 0.0, 0.0, 1.0) glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT) glutSwapBuffers() -- cgit v1.2.3 From cd1657ece1fa199964abd6544b81b394ab9369aa Mon Sep 17 00:00:00 2001 From: Tim Keller Date: Sun, 15 Jun 2025 15:06:34 -0500 Subject: callbacks on lctl, websocket controls --- flaskapi.py | 24 ++++++++++++++++-------- lazycachelist.py | 51 +++++++++++++++++++++++++++++++++++++++------------ pix.py | 5 +++-- requirements.txt | 2 ++ 4 files changed, 60 insertions(+), 22 deletions(-) diff --git a/flaskapi.py b/flaskapi.py index 0791016..a537192 100644 --- a/flaskapi.py +++ b/flaskapi.py @@ -1,7 +1,9 @@ -from flask import Flask, Blueprint, request, send_from_directory, send_file, abort +from flask import Flask, Blueprint, request, send_from_directory, send_file, abort, redirect +from flask_socketio import SocketIO, emit from flask_cors import CORS app = Flask(__name__, static_folder="static/dist", static_url_path="/") +socketio = SocketIO(app, cors_allowed_origins="*") # TODO remove later @app.route("/") @app.route("/slideshow") @@ -13,11 +15,11 @@ def home(): api = Blueprint("api", __name__) CORS(api, origins="*") # For debugging TODO remove later -@api.route("/seek") -def seek(): +#@api.route("/seek") +@socketio.on("seek") +def seek(increment): pd = app.config["pix_display"] - increment = request.args.get("increment", default=1, type=int) pd.queue.put(lambda: pd.seek(increment)) while not pd.queue.empty(): pass @@ -26,7 +28,7 @@ def seek(): "imageIndex": pd.current_texture_index, } -@api.route("/albums/get") +@api.route("/albums") def get_albums(): ic = app.config["immich_connector"] keys = [ "albumName", "albumThumbnailAssetId", "id", "startDate", "endDate", "assetCount", "shared", ] @@ -34,14 +36,20 @@ def get_albums(): key: album[key] for key in keys } for album in ic.load_all_albums() ] -@api.route("/albums/thumb/") -def get_album_thumb(key): +@api.route("/asset//thumbnail", defaults={ "size": "thumbnail" }) +@api.route("/asset/", defaults={ "size": "preview" }) +def get_asset(key, size): # TODO ensure getting actual album thumb ic = app.config["immich_connector"] - image_data, mimetype = ic.load_image(key, size="thumbnail") + image_data, mimetype = ic.load_image(key, size=size) if image_data is None: abort(400) return send_file(image_data, mimetype=mimetype) +@api.route("/redirect/") +def immich_redirect(path): + ic = app.config["immich_connector"] + return redirect(f"{ic.server_url}/{path}") + app.register_blueprint(api, url_prefix="/api") diff --git a/lazycachelist.py b/lazycachelist.py index 0c37ba3..b08b85d 100644 --- a/lazycachelist.py +++ b/lazycachelist.py @@ -1,4 +1,4 @@ -from dataclasses import dataclass +from dataclasses import dataclass, asdict from texture import ImageTextureImmichAsset @@ -14,8 +14,27 @@ class Album: return end - start +@dataclass +class CallbackStateData: + asset_index: int + movement: int + assets: list[str] + + @classmethod + def from_lctl(cls, l): + si = (l.asset_index - 5) % l.asset_count + ei = (l.asset_index + 6) % l.asset_count + sa = l.assets[si:ei] if si < ei else l.assets[si:] + l.assets[:ei] + assets = [ a["id"] for a in sa ] + return cls( + asset_index=l.asset_index, + movement=l.last_movement, + assets=assets, + ) + + class LazyCachingTextureList(): - def __init__(self, immich_connector, album_ids, max_cache_items=100): + def __init__(self, immich_connector, album_ids, max_cache_items=100, change_callback=None): self.immich_connector = immich_connector assert max_cache_items >= 20, "Minimum cache items is 20" # Double small radius @@ -29,11 +48,16 @@ class LazyCachingTextureList(): self.cache_index = 0 self.asset_index = 0 self.asset_count = 0 + self.last_movement = 0 self.cached_items = 0 self.album_keys = album_ids + # TODO simplify album handling, dont need classes, etc. self.albums = self._get_albums() + self.assets = self._get_album_assets() + + self.change_callback = change_callback def index_in_cache_range(self, index): index_range_low = (self.asset_index - self.cache_items_behind) % self.asset_count @@ -54,18 +78,13 @@ class LazyCachingTextureList(): albums.append(Album(id, i, i + asset_count)) i += asset_count + 1 self.asset_count += asset_count - #self.asset_count = sum(( album.assets_count for album in self.albums )) return albums - def _get_album_asset_by_index(self, asset_index): - if asset_index < 0: - asset_index = asset_count - asset_index + def _get_album_assets(self): + assets = [] for album in self.albums: - if album.range_start <= asset_index <= album.range_end: - if album.assets_list is None: - album.assets_list = self.immich_connector.load_album_assets(album.id) - return album.assets_list[asset_index - album.range_start] - raise IndexError("Index out of bounds") + assets += self.immich_connector.load_album_assets(album.id) + return assets def _fill_cache(self, current_radius, final_radius, step): if current_radius >= final_radius: @@ -76,7 +95,7 @@ class LazyCachingTextureList(): asset_index = (self.asset_index + i) % self.asset_count if self.cache[cache_index]: self.cache[cache_index].free() # Since this is a ring buffer, textures outside of range can just get free'd here - asset = self._get_album_asset_by_index(asset_index) + asset = self.assets[asset_index] tex = ImageTextureImmichAsset(asset, asset_index) self.immich_connector.load_texture_async(self, tex) self.cache[cache_index] = tex @@ -87,6 +106,8 @@ class LazyCachingTextureList(): def _update_cache_get_item(self, asset_index): prev_asset_index = self.asset_index self.asset_index = asset_index % self.asset_count + #if prev_asset_index == asset_index: + # return self.cache[self.cache_index] # Movement is the distance between the previous and current index in the assets list # Since the list wraps around, fastest method is just to get the abs min of the 3 cases movement = min( @@ -94,6 +115,7 @@ class LazyCachingTextureList(): asset_index - self.asset_count, # Wrap backwards (0 -> -1) self.asset_count - prev_asset_index, # Wrap forwards (-1 -> 0) key=abs) + self.last_movement = movement self.cache_index = (self.cache_index + movement) % self.cache_length ahead = max(0, self.cache_items_ahead - movement) @@ -114,6 +136,11 @@ class LazyCachingTextureList(): self.cache_items_ahead = ahead self.cache_items_behind = behind + + # Perform callback + if prev_asset_index != asset_index and self.change_callback: + self.change_callback(asdict(CallbackStateData.from_lctl(self))) + return self.cache[self.cache_index] def __len__(self): diff --git a/pix.py b/pix.py index 4064892..45e3f70 100644 --- a/pix.py +++ b/pix.py @@ -6,7 +6,7 @@ from OpenGL.GLUT import glutLeaveMainLoop from lazycachelist import LazyCachingTextureList from window import PixDisplay from immich import ImmichConnector -from flaskapi import app +from flaskapi import app, socketio def handle_sigint(sig, frame): @@ -22,7 +22,7 @@ def handle_sigint(sig, frame): if __name__ == "__main__": immich_connector = ImmichConnector("http://192.168.1.13", "m5nqOoBc4uhAba21gZdCP3z8D3JT4GPxDXL2psd52EA") album_keys = [ "38617851-6b57-44f1-b5f7-82577606afc4" ] - lazy_texture_list = LazyCachingTextureList(immich_connector, album_keys, 30) + lazy_texture_list = LazyCachingTextureList(immich_connector, album_keys, 30, lambda d: socketio.emit("seek", d)) pd = PixDisplay(lazy_texture_list) t1 = Thread(target=immich_connector.idle, daemon=True) @@ -30,6 +30,7 @@ if __name__ == "__main__": app.config["pix_display"] = pd app.config["immich_connector"] = immich_connector + app.config["textures"] = lazy_texture_list flask_thread = Thread(target=app.run, daemon=True, kwargs={ "port": 5000 }) flask_thread.start() diff --git a/requirements.txt b/requirements.txt index 7d9001a..991c1ce 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,6 @@ flask +flask-cors # DEBUG +flask-socketio numpy pillow pyopengl -- cgit v1.2.3 From b8df4605b42d9a61bb4ae4731efabbdc38166063 Mon Sep 17 00:00:00 2001 From: Tim Keller Date: Mon, 16 Jun 2025 21:50:38 -0500 Subject: add config and add application thread manager --- .gitignore | 1 + flaskapi.py | 41 +++++++++++++++--------- immich.py | 20 ++++++++---- lazycachelist.py | 5 +-- manager.py | 97 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ pix.py | 41 ++++++------------------ settings.py | 39 +++++++++++++++++++++++ window.py | 18 ++++++----- 8 files changed, 199 insertions(+), 63 deletions(-) create mode 100644 manager.py create mode 100644 settings.py diff --git a/.gitignore b/.gitignore index cf1238c..4197899 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ __pycache__ *.jpg +*.json diff --git a/flaskapi.py b/flaskapi.py index a537192..45616ec 100644 --- a/flaskapi.py +++ b/flaskapi.py @@ -2,8 +2,14 @@ from flask import Flask, Blueprint, request, send_from_directory, send_file, abo from flask_socketio import SocketIO, emit from flask_cors import CORS +from manager import PixMan + + app = Flask(__name__, static_folder="static/dist", static_url_path="/") -socketio = SocketIO(app, cors_allowed_origins="*") # TODO remove later +api = Blueprint("api", __name__) +socketio = SocketIO(app, cors_allowed_origins="*") # NOTE debug +CORS(api, origins="*") # NOTE debug + @app.route("/") @app.route("/slideshow") @@ -12,44 +18,49 @@ socketio = SocketIO(app, cors_allowed_origins="*") # TODO remove later def home(): return send_from_directory("static/public", "index.html") -api = Blueprint("api", __name__) -CORS(api, origins="*") # For debugging TODO remove later -#@api.route("/seek") @socketio.on("seek") def seek(increment): - pd = app.config["pix_display"] - - pd.queue.put(lambda: pd.seek(increment)) - while not pd.queue.empty(): + if not (display := PixMan().display): + return {} + display.queue.put(lambda: display.seek(increment)) + while not display.queue.empty(): pass - return { - "imageTime": pd.image_time, - "imageIndex": pd.current_texture_index, - } + return { "imageIndex": display.current_texture_index } + @api.route("/albums") def get_albums(): - ic = app.config["immich_connector"] + if not (ic := PixMan().immich_connector): + return {} keys = [ "albumName", "albumThumbnailAssetId", "id", "startDate", "endDate", "assetCount", "shared", ] return [{ key: album[key] for key in keys } for album in ic.load_all_albums() ] + @api.route("/asset//thumbnail", defaults={ "size": "thumbnail" }) @api.route("/asset/", defaults={ "size": "preview" }) def get_asset(key, size): + if not (ic := PixMan().immich_connector): + return {} # TODO ensure getting actual album thumb - ic = app.config["immich_connector"] image_data, mimetype = ic.load_image(key, size=size) if image_data is None: abort(400) return send_file(image_data, mimetype=mimetype) + @api.route("/redirect/") def immich_redirect(path): - ic = app.config["immich_connector"] + if not (ic := PixMan().immich_connector): + return {} return redirect(f"{ic.server_url}/{path}") +@api.route("/config/update", methods=["POST"]) +def config_update(): + return { "success": PixMan().update_config(request.json) } + + app.register_blueprint(api, url_prefix="/api") diff --git a/immich.py b/immich.py index e73b34e..ad39942 100644 --- a/immich.py +++ b/immich.py @@ -3,34 +3,38 @@ from io import BytesIO from queue import Queue from texture import ImageTexture +from manager import PixMan class ImmichConnector: - def __init__(self, server_url, api_key): - self.server_url = server_url.removesuffix("/") - self.api_key = api_key + def __init__(self): + config = PixMan().config + self.server_url = config.immich_url.removesuffix("/") + self.api_key = config.immich_api_key self.texture_load_queue = Queue() def _request(self, endpoint): + if not self.server_url: + return None return requests.get(f"{self.server_url}/api/{endpoint}", headers={ "x-api-key": self.api_key }) def load_all_albums(self): response = self._request("albums") - if response.status_code != 200: return + if not response or response.status_code != 200: return data = response.json() return data def load_album_assets(self, key): response = self._request(f"albums/{key}") - if response.status_code != 200: return + if not response or response.status_code != 200: return data = response.json() return data["assets"] def load_image(self, key, size="preview"): response = self._request(f"assets/{key}/thumbnail?size={size}") - if response.status_code != 200: return None, None + if not response or response.status_code != 200: return None, None image_data = BytesIO(response.content) mimetype = response.headers.get("Content-Type") @@ -47,3 +51,7 @@ class ImmichConnector: continue # Texture was never loaded so it doesn't need to be free'd image_data, _ = self.load_image(image_texture.asset_key, size) image_texture.initialize(image_data) + + def validate_connection(self): + # TODO + return True diff --git a/lazycachelist.py b/lazycachelist.py index b08b85d..3b2744d 100644 --- a/lazycachelist.py +++ b/lazycachelist.py @@ -1,6 +1,7 @@ from dataclasses import dataclass, asdict from texture import ImageTextureImmichAsset +from manager import PixMan @dataclass class Album: @@ -34,8 +35,8 @@ class CallbackStateData: class LazyCachingTextureList(): - def __init__(self, immich_connector, album_ids, max_cache_items=100, change_callback=None): - self.immich_connector = immich_connector + def __init__(self, album_ids, max_cache_items=100, change_callback=None): + self.immich_connector = PixMan().immich_connector assert max_cache_items >= 20, "Minimum cache items is 20" # Double small radius # Ring buffer diff --git a/manager.py b/manager.py new file mode 100644 index 0000000..a7d8ebd --- /dev/null +++ b/manager.py @@ -0,0 +1,97 @@ +import os +import sys +import signal +from threading import Thread +from OpenGL.GLUT import glutLeaveMainLoop + + +class PixMan: + _instance = None + _initialized = False + + @staticmethod + def handle_sigint(sig, frame): + try: + # Threads are started as daemons so will automatically shut down + glutLeaveMainLoop() + sys.exit(0) + except: + pass + finally: + print("Exiting...") + + def __new__(cls, *args, **kwargs): + if cls._instance is None: + cls._instance = super().__new__(cls) + else: + assert not args and not kwargs, f"Singleton {cls.__name__} cannot be called with arguments more than once" + return cls._instance + + def __init__(self): + pass + + @classmethod + def initialize(cls, configfile, app, socketio, host, port): + assert not cls._initialized, "Already initialized" + if cls._initialized: + return + cls._initialized = True + self = cls() + + self.app = app + self.socketio = socketio + self.immich_connector = None + self.texture_list = None + self.display = None + self.t_flask = None + self.t_idle_download = None + + signal.signal(signal.SIGINT, PixMan.handle_sigint) + + self.configfile = configfile + config = Config.load(self.configfile) if os.path.exists(self.configfile) else Config(file=self.configfile) + + self.init_web(host, port) + self.update_config(config) + + def init_web(self, host, port): + self.t_flask = Thread(target=self.app.run, kwargs={ "host": host, "port": port }) + self.t_flask.start() + + def init_window(self): + # Initialize immich connector + self.immich_connector = ImmichConnector() + if not self.immich_connector.validate_connection(): + self.immich_connector = None + return + + # Initialize texture list + change_callback = lambda d: self.socketio.emit("seek", d) + album_keys = [ "38617851-6b57-44f1-b5f7-82577606afc4" ] + self.texture_list = LazyCachingTextureList(album_keys, max_cache_items=self.config.max_cache_assets, change_callback=change_callback) + + # Begin downloading images + self.t_idle_download = Thread(target=self.immich_connector.idle, daemon=True) + self.t_idle_download.start() + + # Create display + self.display = PixDisplay(self.texture_list) + self.display.main({}) # TODO glut args + + def update_config(self, config): + #old_config = self.config + self.config = config + + # Initialize window if immich parameters are valid + if config.immich_url and config.immich_api_key and not self.display: + self.init_window() + + # If all goes well + config.save(self.configfile) + return True + + +from lazycachelist import LazyCachingTextureList +from window import PixDisplay +from immich import ImmichConnector +from settings import Config diff --git a/pix.py b/pix.py index 45e3f70..236d2f1 100644 --- a/pix.py +++ b/pix.py @@ -1,38 +1,15 @@ -import sys -import signal -from threading import Thread -from OpenGL.GLUT import glutLeaveMainLoop +import argparse -from lazycachelist import LazyCachingTextureList -from window import PixDisplay -from immich import ImmichConnector from flaskapi import app, socketio - - -def handle_sigint(sig, frame): - try: - glutLeaveMainLoop() - sys.exit(0) - except: - pass - finally: - print("Exiting on Ctrl+C") +from settings import Config +from manager import PixMan if __name__ == "__main__": - immich_connector = ImmichConnector("http://192.168.1.13", "m5nqOoBc4uhAba21gZdCP3z8D3JT4GPxDXL2psd52EA") - album_keys = [ "38617851-6b57-44f1-b5f7-82577606afc4" ] - lazy_texture_list = LazyCachingTextureList(immich_connector, album_keys, 30, lambda d: socketio.emit("seek", d)) - pd = PixDisplay(lazy_texture_list) - - t1 = Thread(target=immich_connector.idle, daemon=True) - t1.start() - - app.config["pix_display"] = pd - app.config["immich_connector"] = immich_connector - app.config["textures"] = lazy_texture_list - flask_thread = Thread(target=app.run, daemon=True, kwargs={ "port": 5000 }) - flask_thread.start() + p = argparse.ArgumentParser(formatter_class=argparse.ArgumentDefaultsHelpFormatter) + p.add_argument("--config", type=str, help="set config file path", default="config.json") + p.add_argument("--host", type=str, help="set web interface host", default="0.0.0.0") + p.add_argument("--port", type=int, help="set web interface port", default=80) + args = p.parse_args() - signal.signal(signal.SIGINT, handle_sigint) - pd.main(sys.argv) + PixMan.initialize(args.config, app, socketio, args.host, args.port) diff --git a/settings.py b/settings.py new file mode 100644 index 0000000..43cb024 --- /dev/null +++ b/settings.py @@ -0,0 +1,39 @@ +from dataclasses import dataclass, asdict, field +import json + + +@dataclass +class AlbumList: + name: str + album_keys: list[str] + + +@dataclass +class Config: + # Immich server + immich_url: str = "" + immich_api_key: str = "" + # Display + image_duration: float = 10.0 + transition_duration: float = 0.5 + max_framerate: float = 30.0 + auto_transition: bool = True + display_size: str = "preview" # 'fullsize', 'preview', 'thumbnail' + # Cache + max_cache_assets: int = 100 + # Albums data + album_lists: list[AlbumList] = field(default_factory=list) + album_list_selected: int = None + + def __post_init__(self): + self.album_lists = [ AlbumList(*a) for a in self.album_lists ] + + @classmethod + def load(cls, filepath): + with open(filepath, "r") as fp: + return cls(**json.load(fp)) + + def save(self, filepath): + data = asdict(self) + with open(filepath, "w") as fp: + json.dump(data, fp, indent=2) diff --git a/window.py b/window.py index 1523f85..f422381 100644 --- a/window.py +++ b/window.py @@ -5,7 +5,7 @@ from time import time from queue import Queue from renderer import ImageRenderer, TransitionMix - +from manager import PixMan class PixDisplay: def __init__(self, textures): @@ -18,13 +18,14 @@ class PixDisplay: self.renderer = None self.win_w = 0 self.win_h = 0 - self.max_framerate = 30 - self.image_duration = 2.0 - self.transition_duration = 0.5 - self.auto_transition = True - self.transition_reverse = False + config = PixMan().config + self.max_framerate = config.max_framerate + self.image_duration = config.image_duration + self.transition_duration = config.transition_duration + self.auto_transition = config.auto_transition + self.transition_reverse = False self.text_prev = None self.tex = None @@ -33,12 +34,13 @@ class PixDisplay: @property def max_framerate(self): - return self._max_fps + #return int(1000/int(1000/self.frame_time)) + return self.frame_time @max_framerate.setter def max_framerate(self, max_fps): self._max_fps = max_fps # This is just for the getter since otherwise e.g. int(1000/int(1000/60)) would round to 62 - self.frame_time = int(1000 / 60) # In ms + self.frame_time = int(1000 / max_fps) # In ms def increment_texture_index(self, increment): self.transition_reverse = increment < 0 -- cgit v1.2.3 From c5465f7ceed37f1ba6575248e1035e1430e78921 Mon Sep 17 00:00:00 2001 From: Tim Keller Date: Mon, 16 Jun 2025 21:58:13 -0500 Subject: config route and fix config saving/creation --- flaskapi.py | 6 +++++- manager.py | 2 +- settings.py | 6 ++++-- 3 files changed, 10 insertions(+), 4 deletions(-) diff --git a/flaskapi.py b/flaskapi.py index 45616ec..bb670a2 100644 --- a/flaskapi.py +++ b/flaskapi.py @@ -1,4 +1,4 @@ -from flask import Flask, Blueprint, request, send_from_directory, send_file, abort, redirect +from flask import Flask, Blueprint, request, send_from_directory, send_file, abort, redirect, jsonify from flask_socketio import SocketIO, emit from flask_cors import CORS @@ -62,5 +62,9 @@ def immich_redirect(path): def config_update(): return { "success": PixMan().update_config(request.json) } +@api.route("/config") +def config(): + return jsonify(PixMan().config) + app.register_blueprint(api, url_prefix="/api") diff --git a/manager.py b/manager.py index a7d8ebd..b2e101f 100644 --- a/manager.py +++ b/manager.py @@ -49,7 +49,7 @@ class PixMan: signal.signal(signal.SIGINT, PixMan.handle_sigint) self.configfile = configfile - config = Config.load(self.configfile) if os.path.exists(self.configfile) else Config(file=self.configfile) + config = Config.load(self.configfile) if os.path.exists(self.configfile) else Config() self.init_web(host, port) self.update_config(config) diff --git a/settings.py b/settings.py index 43cb024..f11e930 100644 --- a/settings.py +++ b/settings.py @@ -28,12 +28,14 @@ class Config: def __post_init__(self): self.album_lists = [ AlbumList(*a) for a in self.album_lists ] + def __dict__(self): + return asdict(self) + @classmethod def load(cls, filepath): with open(filepath, "r") as fp: return cls(**json.load(fp)) def save(self, filepath): - data = asdict(self) with open(filepath, "w") as fp: - json.dump(data, fp, indent=2) + json.dump(asdict(self), fp, indent=2) -- cgit v1.2.3 From c9e7eb40fc5f408cc4177763fd4e82a3632388a1 Mon Sep 17 00:00:00 2001 From: Tim Keller Date: Tue, 17 Jun 2025 22:34:39 -0500 Subject: cast config from api --- flaskapi.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/flaskapi.py b/flaskapi.py index bb670a2..beabea0 100644 --- a/flaskapi.py +++ b/flaskapi.py @@ -3,6 +3,7 @@ from flask_socketio import SocketIO, emit from flask_cors import CORS from manager import PixMan +from settings import Config app = Flask(__name__, static_folder="static/dist", static_url_path="/") @@ -60,7 +61,7 @@ def immich_redirect(path): @api.route("/config/update", methods=["POST"]) def config_update(): - return { "success": PixMan().update_config(request.json) } + return { "success": PixMan().update_config(Config(**request.json)) } @api.route("/config") def config(): -- cgit v1.2.3 From ce64f1a42c9570efa75cc2f568e59d683f499bdd Mon Sep 17 00:00:00 2001 From: Tim Keller Date: Fri, 20 Jun 2025 22:32:28 -0500 Subject: config update and more endpoints for api fromtend --- flaskapi.py | 27 ++++++++++++++++++++++----- immich.py | 7 +++++++ lazycachelist.py | 2 ++ manager.py | 19 +++++++++++++++---- settings.py | 16 +++++----------- todo | 2 ++ 6 files changed, 53 insertions(+), 20 deletions(-) create mode 100644 todo diff --git a/flaskapi.py b/flaskapi.py index beabea0..a539dc5 100644 --- a/flaskapi.py +++ b/flaskapi.py @@ -30,16 +30,33 @@ def seek(increment): return { "imageIndex": display.current_texture_index } +@api.route("/albums/update", methods=["POST"]) +def albums_update(): + return { "success": PixMan().update_config(album_list=request.json) } + @api.route("/albums") -def get_albums(): +def albums_get(): if not (ic := PixMan().immich_connector): return {} keys = [ "albumName", "albumThumbnailAssetId", "id", "startDate", "endDate", "assetCount", "shared", ] + selected_albums = PixMan().config.album_list return [{ - key: album[key] for key in keys - } for album in ic.load_all_albums() ] + key: album.get(key, None) for key in keys + } | { "selected": album in selected_albums } for album in ic.load_all_albums() if album["assetCount"] ] + + +@api.route("/asset//filename") +def get_asset_name(key): + if not (ic := PixMan().immich_connector): + return {} + # TODO ensure getting actual album thumb + name = ic.load_image_filename(key) + if name is None: + abort(400) + return { "filename": name } +@api.route("/asset//fullsize", defaults={ "size": "fullsize" }) @api.route("/asset//thumbnail", defaults={ "size": "thumbnail" }) @api.route("/asset/", defaults={ "size": "preview" }) def get_asset(key, size): @@ -61,10 +78,10 @@ def immich_redirect(path): @api.route("/config/update", methods=["POST"]) def config_update(): - return { "success": PixMan().update_config(Config(**request.json)) } + return { "success": PixMan().update_config(**request.json) } @api.route("/config") -def config(): +def config_get(): return jsonify(PixMan().config) diff --git a/immich.py b/immich.py index ad39942..0f319c5 100644 --- a/immich.py +++ b/immich.py @@ -40,6 +40,13 @@ class ImmichConnector: mimetype = response.headers.get("Content-Type") return image_data, mimetype + def load_image_filename(self, key): + response = self._request(f"assets/{key}") + if not response or response.status_code != 200: return None, None + + data = response.json() + return data["originalFileName"] + def load_texture_async(self, texture_list, image_texture): self.texture_load_queue.put((texture_list, image_texture)) diff --git a/lazycachelist.py b/lazycachelist.py index 3b2744d..5243def 100644 --- a/lazycachelist.py +++ b/lazycachelist.py @@ -20,6 +20,7 @@ class CallbackStateData: asset_index: int movement: int assets: list[str] + current_asset: str @classmethod def from_lctl(cls, l): @@ -31,6 +32,7 @@ class CallbackStateData: asset_index=l.asset_index, movement=l.last_movement, assets=assets, + current_asset=assets[5], ) diff --git a/manager.py b/manager.py index b2e101f..27ebab3 100644 --- a/manager.py +++ b/manager.py @@ -52,7 +52,7 @@ class PixMan: config = Config.load(self.configfile) if os.path.exists(self.configfile) else Config() self.init_web(host, port) - self.update_config(config) + self.replace_config(config) def init_web(self, host, port): self.t_flask = Thread(target=self.app.run, kwargs={ "host": host, "port": port }) @@ -78,16 +78,27 @@ class PixMan: self.display = PixDisplay(self.texture_list) self.display.main({}) # TODO glut args - def update_config(self, config): + def replace_config(self, config): #old_config = self.config self.config = config # Initialize window if immich parameters are valid - if config.immich_url and config.immich_api_key and not self.display: + if self.config.immich_url and self.config.immich_api_key and not self.display: self.init_window() # If all goes well - config.save(self.configfile) + self.config.save(self.configfile) + return True + + def update_config(self, **config): + self.config.update(**config) + + # Initialize window if immich parameters are valid + if self.config.immich_url and self.config.immich_api_key and not self.display: + self.init_window() + + # If all goes well + self.config.save(self.configfile) return True diff --git a/settings.py b/settings.py index f11e930..e6b3df8 100644 --- a/settings.py +++ b/settings.py @@ -2,12 +2,6 @@ from dataclasses import dataclass, asdict, field import json -@dataclass -class AlbumList: - name: str - album_keys: list[str] - - @dataclass class Config: # Immich server @@ -22,11 +16,7 @@ class Config: # Cache max_cache_assets: int = 100 # Albums data - album_lists: list[AlbumList] = field(default_factory=list) - album_list_selected: int = None - - def __post_init__(self): - self.album_lists = [ AlbumList(*a) for a in self.album_lists ] + album_list: list[str] = field(default_factory=list) def __dict__(self): return asdict(self) @@ -39,3 +29,7 @@ class Config: def save(self, filepath): with open(filepath, "w") as fp: json.dump(asdict(self), fp, indent=2) + + def update(self, **config): + for key, value in config.items(): + setattr(self, key, value) diff --git a/todo b/todo new file mode 100644 index 0000000..0970123 --- /dev/null +++ b/todo @@ -0,0 +1,2 @@ +push current images data on new socket connection opening up +decrease size of assets in memory -- cgit v1.2.3 From 8fa092b81aa09239e15b44e2002c7d18d3f4244b Mon Sep 17 00:00:00 2001 From: Tim Keller Date: Fri, 20 Jun 2025 22:35:34 -0500 Subject: fix selected attr on /albums --- flaskapi.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flaskapi.py b/flaskapi.py index a539dc5..2e2ced4 100644 --- a/flaskapi.py +++ b/flaskapi.py @@ -42,7 +42,7 @@ def albums_get(): selected_albums = PixMan().config.album_list return [{ key: album.get(key, None) for key in keys - } | { "selected": album in selected_albums } for album in ic.load_all_albums() if album["assetCount"] ] + } | { "selected": album["id"] in selected_albums } for album in ic.load_all_albums() if album["assetCount"] ] @api.route("/asset//filename") -- cgit v1.2.3 From 0b0c1978c4f7b57a240575de56b8e40d29c3c219 Mon Sep 17 00:00:00 2001 From: Tim Keller Date: Tue, 24 Jun 2025 18:34:07 -0500 Subject: live reload window display config --- flaskapi.py | 4 ++-- manager.py | 21 +++++++-------------- window.py | 15 +++++++++------ 3 files changed, 18 insertions(+), 22 deletions(-) diff --git a/flaskapi.py b/flaskapi.py index 2e2ced4..633eed2 100644 --- a/flaskapi.py +++ b/flaskapi.py @@ -32,7 +32,7 @@ def seek(increment): @api.route("/albums/update", methods=["POST"]) def albums_update(): - return { "success": PixMan().update_config(album_list=request.json) } + return { "success": PixMan().update_config({ "album_list": request.json }) } @api.route("/albums") def albums_get(): @@ -78,7 +78,7 @@ def immich_redirect(path): @api.route("/config/update", methods=["POST"]) def config_update(): - return { "success": PixMan().update_config(**request.json) } + return { "success": PixMan().update_config(request.json) } @api.route("/config") def config_get(): diff --git a/manager.py b/manager.py index 27ebab3..8f7ed80 100644 --- a/manager.py +++ b/manager.py @@ -52,7 +52,7 @@ class PixMan: config = Config.load(self.configfile) if os.path.exists(self.configfile) else Config() self.init_web(host, port) - self.replace_config(config) + self.update_config(config, replace=True) def init_web(self, host, port): self.t_flask = Thread(target=self.app.run, kwargs={ "host": host, "port": port }) @@ -78,24 +78,17 @@ class PixMan: self.display = PixDisplay(self.texture_list) self.display.main({}) # TODO glut args - def replace_config(self, config): - #old_config = self.config - self.config = config + def update_config(self, config, replace=False): + if replace: + self.config = config + else: + self.config.update(**config) # Initialize window if immich parameters are valid if self.config.immich_url and self.config.immich_api_key and not self.display: self.init_window() - # If all goes well - self.config.save(self.configfile) - return True - - def update_config(self, **config): - self.config.update(**config) - - # Initialize window if immich parameters are valid - if self.config.immich_url and self.config.immich_api_key and not self.display: - self.init_window() + self.display.update_config() # If all goes well self.config.save(self.configfile) diff --git a/window.py b/window.py index f422381..0bb5b3d 100644 --- a/window.py +++ b/window.py @@ -19,12 +19,6 @@ class PixDisplay: self.win_w = 0 self.win_h = 0 - config = PixMan().config - self.max_framerate = config.max_framerate - self.image_duration = config.image_duration - self.transition_duration = config.transition_duration - self.auto_transition = config.auto_transition - self.transition_reverse = False self.text_prev = None self.tex = None @@ -32,6 +26,15 @@ class PixDisplay: self._force_redraw = False self.queue = Queue() + self.update_config() + + def update_config(self): + config = PixMan().config + self.max_framerate = config.max_framerate + self.image_duration = config.image_duration + self.transition_duration = config.transition_duration + self.auto_transition = config.auto_transition + @property def max_framerate(self): #return int(1000/int(1000/self.frame_time)) -- cgit v1.2.3 From 2f03f39e24053377dce108e45fde13ccd1e0ae22 Mon Sep 17 00:00:00 2001 From: Tim Keller Date: Tue, 24 Jun 2025 18:56:53 -0500 Subject: window can now handle no selected albums --- lazycachelist.py | 2 ++ manager.py | 12 +++++++++--- settings.py | 4 ++-- window.py | 9 +++++++-- 4 files changed, 20 insertions(+), 7 deletions(-) diff --git a/lazycachelist.py b/lazycachelist.py index 5243def..99212ab 100644 --- a/lazycachelist.py +++ b/lazycachelist.py @@ -150,6 +150,8 @@ class LazyCachingTextureList(): return self.asset_count def __getitem__(self, index): + if not self.asset_count: + raise IndexError("Index out of bounds") i = index % self.asset_count if abs(index) > i: raise IndexError("Index out of bounds") diff --git a/manager.py b/manager.py index 8f7ed80..918a0c4 100644 --- a/manager.py +++ b/manager.py @@ -1,6 +1,7 @@ import os import sys import signal +import copy from threading import Thread from OpenGL.GLUT import glutLeaveMainLoop @@ -49,10 +50,10 @@ class PixMan: signal.signal(signal.SIGINT, PixMan.handle_sigint) self.configfile = configfile - config = Config.load(self.configfile) if os.path.exists(self.configfile) else Config() + self.config = Config.load(self.configfile) if os.path.exists(self.configfile) else Config() self.init_web(host, port) - self.update_config(config, replace=True) + self.update_config(self.config, replace=True) def init_web(self, host, port): self.t_flask = Thread(target=self.app.run, kwargs={ "host": host, "port": port }) @@ -67,7 +68,7 @@ class PixMan: # Initialize texture list change_callback = lambda d: self.socketio.emit("seek", d) - album_keys = [ "38617851-6b57-44f1-b5f7-82577606afc4" ] + album_keys = [ ] self.texture_list = LazyCachingTextureList(album_keys, max_cache_items=self.config.max_cache_assets, change_callback=change_callback) # Begin downloading images @@ -79,6 +80,7 @@ class PixMan: self.display.main({}) # TODO glut args def update_config(self, config, replace=False): + oldconfig = copy.deepcopy(self.config) if replace: self.config = config else: @@ -90,6 +92,10 @@ class PixMan: self.display.update_config() + if oldconfig.album_list != self.config.album_list: + self.texture_list = LazyCachingTextureList(album_keys) + self.display.update_textures() + # If all goes well self.config.save(self.configfile) return True diff --git a/settings.py b/settings.py index e6b3df8..69e40ca 100644 --- a/settings.py +++ b/settings.py @@ -18,8 +18,8 @@ class Config: # Albums data album_list: list[str] = field(default_factory=list) - def __dict__(self): - return asdict(self) + #def __dict__(self): + # return asdict(self) @classmethod def load(cls, filepath): diff --git a/window.py b/window.py index 0bb5b3d..5387930 100644 --- a/window.py +++ b/window.py @@ -35,6 +35,10 @@ class PixDisplay: self.transition_duration = config.transition_duration self.auto_transition = config.auto_transition + def update_textures(self, textures): + self.textures = textures + self.current_texture_index = 0 + @property def max_framerate(self): #return int(1000/int(1000/self.frame_time)) @@ -68,9 +72,10 @@ class PixDisplay: self.last_time = current_time if not self.tex or not self.tex.initialized or not self.tex.id: - self.increment_texture_index(0) + if self.textures.asset_count > 0: + self.increment_texture_index(0) # Draw black window if no textures are available - if not self.tex.id: + if not self.tex or not self.tex.id: glClearColor(0.0, 0.0, 0.0, 1.0) glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT) glutSwapBuffers() -- cgit v1.2.3 From 4c3d572eb850c32a45ec9cbaf82688d45c1eebf4 Mon Sep 17 00:00:00 2001 From: Tim Keller Date: Tue, 24 Jun 2025 19:16:36 -0500 Subject: add ability to change out albums list during runtime --- immich.py | 2 +- lazycachelist.py | 18 +++++++++++++++--- manager.py | 17 ++++++++++++----- renderer.py | 1 + 4 files changed, 29 insertions(+), 9 deletions(-) diff --git a/immich.py b/immich.py index 0f319c5..8c4f3e6 100644 --- a/immich.py +++ b/immich.py @@ -54,7 +54,7 @@ class ImmichConnector: size = "preview" # TODO while True: texture_list, image_texture = self.texture_load_queue.get() - if not texture_list.index_in_cache_range(image_texture.asset_index): + if not texture_list.index_in_cache_range(image_texture.asset_index) or texture_list.void: continue # Texture was never loaded so it doesn't need to be free'd image_data, _ = self.load_image(image_texture.asset_key, size) image_texture.initialize(image_data) diff --git a/lazycachelist.py b/lazycachelist.py index 99212ab..b4d003a 100644 --- a/lazycachelist.py +++ b/lazycachelist.py @@ -39,13 +39,12 @@ class CallbackStateData: class LazyCachingTextureList(): def __init__(self, album_ids, max_cache_items=100, change_callback=None): self.immich_connector = PixMan().immich_connector + self.void = False assert max_cache_items >= 20, "Minimum cache items is 20" # Double small radius # Ring buffer - self.cache_length = max_cache_items - self.cache = [None] * max_cache_items + self.max_cache_items = max_cache_items self.radius_small = 10 - self.radius_large = int(max_cache_items / 2) self.cache_items_behind = 0 self.cache_items_ahead = 0 self.cache_index = 0 @@ -62,6 +61,19 @@ class LazyCachingTextureList(): self.change_callback = change_callback + @property + def max_cache_items(self): + return self.cache_length + + @max_cache_items.setter + def max_cache_items(self, max_cache_items): + self.cache_length = max_cache_items + self.cache = [None] * max_cache_items + self.radius_large = int(max_cache_items / 2) + + def free(self): + self.void = True + def index_in_cache_range(self, index): index_range_low = (self.asset_index - self.cache_items_behind) % self.asset_count index_range_high = (self.asset_index + self.cache_items_ahead ) % self.asset_count diff --git a/manager.py b/manager.py index 918a0c4..af1e4a7 100644 --- a/manager.py +++ b/manager.py @@ -67,9 +67,7 @@ class PixMan: return # Initialize texture list - change_callback = lambda d: self.socketio.emit("seek", d) - album_keys = [ ] - self.texture_list = LazyCachingTextureList(album_keys, max_cache_items=self.config.max_cache_assets, change_callback=change_callback) + self.update_textures() # Begin downloading images self.t_idle_download = Thread(target=self.immich_connector.idle, daemon=True) @@ -79,6 +77,14 @@ class PixMan: self.display = PixDisplay(self.texture_list) self.display.main({}) # TODO glut args + def update_textures(self): + if self.texture_list: + self.texture_list.free() + change_callback = lambda d: self.socketio.emit("seek", d) + self.texture_list = LazyCachingTextureList(self.config.album_list, max_cache_items=self.config.max_cache_assets, change_callback=change_callback) + if self.display: + self.display.update_textures(self.texture_list) + def update_config(self, config, replace=False): oldconfig = copy.deepcopy(self.config) if replace: @@ -93,8 +99,9 @@ class PixMan: self.display.update_config() if oldconfig.album_list != self.config.album_list: - self.texture_list = LazyCachingTextureList(album_keys) - self.display.update_textures() + self.update_textures() + else: + self.texture_list.max_cache_items = self.config.max_cache_assets # If all goes well self.config.save(self.configfile) diff --git a/renderer.py b/renderer.py index 909ad2b..ae4be12 100644 --- a/renderer.py +++ b/renderer.py @@ -98,6 +98,7 @@ class ImageRenderer: def draw_image(self, tex, win_w, win_h, alpha): glUseProgram(self.shader) glBindVertexArray(self.vao) + # FIXME check if tex.id is None glBindTexture(GL_TEXTURE_2D, tex.id) # Calculate aspect ratios -- cgit v1.2.3