summaryrefslogtreecommitdiff
path: root/src/server
diff options
context:
space:
mode:
Diffstat (limited to 'src/server')
-rw-r--r--src/server/__main__.py20
-rw-r--r--src/server/flaskapi.py90
-rw-r--r--src/server/immich.py64
-rw-r--r--src/server/lazycachelist.py170
-rw-r--r--src/server/manager.py127
-rw-r--r--src/server/renderer.py172
-rw-r--r--src/server/settings.py32
-rw-r--r--src/server/texture.py67
-rw-r--r--src/server/window.py154
9 files changed, 896 insertions, 0 deletions
diff --git a/src/server/__main__.py b/src/server/__main__.py
new file mode 100644
index 0000000..508a13a
--- /dev/null
+++ b/src/server/__main__.py
@@ -0,0 +1,20 @@
+import argparse
+
+from .flaskapi import app, socketio
+from .settings import Config
+from .manager import PixMan
+
+
+def main():
+ p = argparse.ArgumentParser(formatter_class=argparse.ArgumentDefaultsHelpFormatter)
+ p.add_argument("--fullscreen", help="start viewer window in fullscreen", action="store_true")
+ 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=8080)
+ args = p.parse_args()
+
+ PixMan.initialize(args, app, socketio)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/src/server/flaskapi.py b/src/server/flaskapi.py
new file mode 100644
index 0000000..6b910fa
--- /dev/null
+++ b/src/server/flaskapi.py
@@ -0,0 +1,90 @@
+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
+from engineio.async_drivers import threading # For pyinstaller
+
+from .manager import PixMan
+from .settings import Config
+
+
+static_folder=PixMan().static_dir
+app = Flask(__name__, static_folder=static_folder, static_url_path="/")
+api = Blueprint("api", __name__)
+socketio = SocketIO(app, async_mode="threading", cors_allowed_origins="*") # NOTE debug, async_mode for pyinstaller
+CORS(api, origins="*") # NOTE debug
+
+
+@app.route("/")
+@app.route("/slideshow")
+@app.route("/albums")
+@app.route("/settings")
+def home():
+ return send_from_directory(static_folder, "index.html")
+
+
+@socketio.on("seek")
+def seek(increment):
+ if not (display := PixMan().display):
+ return {}
+ display.queue.put(lambda: display.seek(increment))
+ while not display.queue.empty():
+ pass
+ 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 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.get(key, None) for key in keys
+ } | { "selected": album["id"] in selected_albums } for album in ic.load_all_albums() if album["assetCount"] ]
+
+
+@api.route("/asset/<key>/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/<key>/fullsize", defaults={ "size": "fullsize" })
+@api.route("/asset/<key>/thumbnail", defaults={ "size": "thumbnail" })
+@api.route("/asset/<key>", defaults={ "size": "preview" })
+def get_asset(key, size):
+ if not (ic := PixMan().immich_connector):
+ return {}
+ # TODO ensure getting actual album thumb
+ 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/<path:path>")
+def immich_redirect(path):
+ 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) }
+
+@api.route("/config")
+def config_get():
+ return jsonify(PixMan().config)
+
+
+app.register_blueprint(api, url_prefix="/api")
diff --git a/src/server/immich.py b/src/server/immich.py
new file mode 100644
index 0000000..d5c5fd1
--- /dev/null
+++ b/src/server/immich.py
@@ -0,0 +1,64 @@
+import requests
+from io import BytesIO
+from queue import Queue
+
+from .texture import ImageTexture
+from .manager import PixMan
+
+
+class ImmichConnector:
+ 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 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 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 not response or response.status_code != 200: return None, None
+
+ image_data = BytesIO(response.content)
+ 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))
+
+ 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) 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)
+
+ def validate_connection(self):
+ # TODO
+ return True
diff --git a/src/server/lazycachelist.py b/src/server/lazycachelist.py
new file mode 100644
index 0000000..c224a8c
--- /dev/null
+++ b/src/server/lazycachelist.py
@@ -0,0 +1,170 @@
+from dataclasses import dataclass, asdict
+
+from .texture import ImageTextureImmichAsset
+from .manager import PixMan
+
+@dataclass
+class Album:
+ id: str
+ range_start: int
+ range_end: int
+ assets_list: list[str] = None
+
+ @property
+ def assets_count(self):
+ return end - start
+
+
+@dataclass
+class CallbackStateData:
+ asset_index: int
+ movement: int
+ assets: list[str]
+ current_asset: 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,
+ current_asset=assets[5],
+ )
+
+
+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.max_cache_items = max_cache_items
+ self.radius_small = 10
+ self.cache_items_behind = 0
+ self.cache_items_ahead = 0
+ 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
+
+ @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
+ 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
+ return albums
+
+ def _get_album_assets(self):
+ assets = []
+ for album in self.albums:
+ 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:
+ 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.assets[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
+ #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(
+ 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.last_movement = movement
+ 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
+
+ # 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):
+ 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")
+ return self._update_cache_get_item(index)
diff --git a/src/server/manager.py b/src/server/manager.py
new file mode 100644
index 0000000..02363b2
--- /dev/null
+++ b/src/server/manager.py
@@ -0,0 +1,127 @@
+import os
+import sys
+import copy
+from threading import Thread
+from pathlib import Path
+
+
+class PixMan:
+ _instance = None
+ _initialized = False
+
+ 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, args, app, socketio):
+ assert not cls._initialized, "Already initialized"
+ if cls._initialized:
+ return
+ cls._initialized = True
+ self = cls()
+
+ self.args = args
+ 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
+
+ self.configfile = self.args.config
+ self.config = Config.load(self.configfile) if os.path.exists(self.configfile) else Config()
+
+ self.init_web(self.args.host, self.args.port)
+ 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 })
+ 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
+ self.update_textures()
+
+ # 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(fullscreen=self.args.fullscreen)
+ self.die()
+
+ 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:
+ self.config = config
+ else:
+ self.config.update(**config)
+
+ if self.display:
+ self.display.update_config()
+
+ if oldconfig.album_list != self.config.album_list:
+ self.update_textures()
+ elif self.texture_list:
+ self.texture_list.max_cache_items = self.config.max_cache_assets
+
+ # If all goes well
+ self.config.save(self.configfile)
+
+ # 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()
+
+ return True
+
+ def die(self):
+ # Join threads and exit
+ self.t_flask.join()
+ self.t_idle_download.join()
+ sys.exit(0)
+
+ @property
+ def frozen(self):
+ # For pyinstaller
+ # https://api.arcade.academy/en/latest/tutorials/bundling_with_pyinstaller/index.html#handling-data-files
+ if getattr(sys, "frozen", False) and hasattr(sys, "_MEIPASS"):
+ return True
+ return False
+
+ @property
+ def static_dir(self):
+ user_assets = os.environ.get("IMMICH_FRAME_STATIC_WEB_ASSETS")
+ if user_assets:
+ return Path(user_assets)
+ if self.frozen:
+ return Path(sys._MEIPASS) / "dist"
+ return Path("../../dist")
+
+
+from .lazycachelist import LazyCachingTextureList
+from .window import PixDisplay
+from .immich import ImmichConnector
+from .settings import Config
diff --git a/src/server/renderer.py b/src/server/renderer.py
new file mode 100644
index 0000000..52572b1
--- /dev/null
+++ b/src/server/renderer.py
@@ -0,0 +1,172 @@
+import pygame
+import numpy as np
+from OpenGL.GL import *
+from typing import Protocol
+
+class ImageRenderer:
+ def __init__(self):
+ # Setup shader and quad
+ self.shader = self._init_shader()
+ self.vao = 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_cls):
+ self.transition = transition_cls(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
+
+ @staticmethod
+ def _init_shader():
+ vertex_src = """
+ #version 300 es
+ precision mediump float; // Precision for float variables (mandatory for ES)
+ 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 300 es
+ precision mediump float; // Precision for float variables (mandatory for ES)
+ 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
+
+ @staticmethod
+ def _init_quad():
+ # 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
+ vao = glGenVertexArrays(1)
+ vbo = glGenBuffers(1)
+ glBindVertexArray(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)
+ return vao
+
+ def draw_image(self, tex, win_w, win_h, alpha):
+ if not tex.initialized:
+ return
+ glUseProgram(self.shader)
+ glBindVertexArray(self.vao)
+ # FIXME check if tex.id is None
+ 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_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)
+
+ pygame.display.flip()
+
+ 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, reversed)
+
+ pygame.display.flip()
+
+
+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, reversed):
+ pass
+
+
+class TransitionMix(Transition):
+ 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:
+ alpha = 1.0
+
+ # 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/src/server/settings.py b/src/server/settings.py
new file mode 100644
index 0000000..ce76e82
--- /dev/null
+++ b/src/server/settings.py
@@ -0,0 +1,32 @@
+from dataclasses import dataclass, asdict, field
+import json
+
+
+@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_list: list[str] = field(default_factory=list)
+
+ @classmethod
+ def load(cls, filepath):
+ with open(filepath, "r") as fp:
+ return cls(**json.load(fp))
+
+ 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/src/server/texture.py b/src/server/texture.py
new file mode 100644
index 0000000..3a56b5f
--- /dev/null
+++ b/src/server/texture.py
@@ -0,0 +1,67 @@
+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 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
+ self._img_data = img.tobytes("raw", "RGBA", 0, -1) # Convert image data to bytes
+ self.initialized = True
+
+ 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}")
+
+ 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):
+ 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) 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)
+ 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/src/server/window.py b/src/server/window.py
new file mode 100644
index 0000000..4597c54
--- /dev/null
+++ b/src/server/window.py
@@ -0,0 +1,154 @@
+import pygame
+from pygame.locals import *
+from OpenGL.GL import *
+from queue import Queue
+
+from .renderer import ImageRenderer, TransitionMix
+from .manager import PixMan
+
+class PixDisplay:
+ def __init__(self, textures):
+ self.screen = None
+ self.last_time = 0
+ self.start_time = 0
+ self.image_time = 0
+ self.paused = False
+ self.textures = textures
+ self.current_texture_index = 0
+ self.renderer = None
+ self.win_w = 0
+ self.win_h = 0
+
+ self.transition_reverse = False
+ self.text_prev = None
+ self.tex = None
+
+ 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
+
+ def update_textures(self, textures):
+ self.textures = textures
+ self.current_texture_index = 0
+
+ def increment_texture_index(self, increment):
+ 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]
+
+ 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()
+
+ # Main display function
+ def display(self):
+ # Calculate timings
+ alive_time = pygame.time.get_ticks() / 1000
+ delta_time = alive_time - self.last_time
+ self.last_time = alive_time
+
+ if not self.tex or not self.tex.initialized or not self.tex.id:
+ if self.textures.asset_count > 0:
+ self.increment_texture_index(0)
+ # Draw black window if no textures are available
+ 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)
+ pygame.display.flip()
+ return
+
+ # Run queue events
+ while not self.queue.empty():
+ f = self.queue.get() # Get the task and its data
+ f()
+
+ # Progress image 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
+ self.win_w, self.win_h = self.screen.get_size()
+
+ # Draw static image except during a transition
+ if self.image_time < self.image_duration:
+ # 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
+ if self.auto_transition:
+ self.increment_texture_index(1)
+ self.auto_transition = False
+
+ 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.auto_transition = True
+
+ def seek(self, increment):
+ self.auto_transition = False
+ self.increment_texture_index(increment)
+ self.image_time = self.image_duration
+
+ # Initialization and main loop
+ def main(self, fullscreen=False):
+ # Initialize the window
+ pygame.init()
+ self.screen = pygame.display.set_mode((0, 0), DOUBLEBUF | OPENGL | (FULLSCREEN if fullscreen else 0))
+ pygame.mouse.set_visible(False)
+
+ # Set up the OpenGL viewport and projection
+ glEnable(GL_TEXTURE_2D)
+ glMatrixMode(GL_PROJECTION)
+ glLoadIdentity()
+ 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)
+
+ # Setup renderer and slide timing
+ self.renderer = ImageRenderer()
+ self.renderer.set_transition(TransitionMix)
+ self.image_time = 0
+ self.last_time = 0
+
+ # Run display
+ clock = pygame.time.Clock()
+ while True:
+ for event in pygame.event.get():
+ if event.type == QUIT:
+ pygame.quit()
+ return
+ elif event.type == KEYDOWN:
+ # Quit with escape or ctrl+q
+ if event.key == pygame.K_ESCAPE or event.key == pygame.K_q and event.mod & pygame.KMOD_CTRL:
+ pygame.quit()
+ return
+ # Seek with left/right arrow
+ elif event.key == pygame.K_LEFT:
+ self.seek(-1)
+ elif event.key == pygame.K_RIGHT:
+ self.seek(+1)
+ self.display()
+ clock.tick(self.max_framerate)