diff options
| -rw-r--r-- | .gitignore | 3 | ||||
| -rw-r--r-- | flaskapi.py | 88 | ||||
| -rw-r--r-- | immich.py | 64 | ||||
| -rw-r--r-- | lazycachelist.py | 170 | ||||
| -rw-r--r-- | manager.py | 114 | ||||
| -rw-r--r-- | pix.py | 15 | ||||
| -rw-r--r-- | renderer.py | 169 | ||||
| -rw-r--r-- | requirements.txt | 7 | ||||
| -rw-r--r-- | settings.py | 35 | ||||
| -rw-r--r-- | texture.py | 67 | ||||
| -rw-r--r-- | todo | 2 | ||||
| -rw-r--r-- | window.py | 169 |
12 files changed, 903 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4197899 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +__pycache__ +*.jpg +*.json diff --git a/flaskapi.py b/flaskapi.py new file mode 100644 index 0000000..633eed2 --- /dev/null +++ b/flaskapi.py @@ -0,0 +1,88 @@ +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 manager import PixMan +from settings import Config + + +app = Flask(__name__, static_folder="static/dist", static_url_path="/") +api = Blueprint("api", __name__) +socketio = SocketIO(app, cors_allowed_origins="*") # NOTE debug +CORS(api, origins="*") # NOTE debug + + +@app.route("/") +@app.route("/slideshow") +@app.route("/albums") +@app.route("/settings") +def home(): + return send_from_directory("static/public", "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/immich.py b/immich.py new file mode 100644 index 0000000..8c4f3e6 --- /dev/null +++ b/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/lazycachelist.py b/lazycachelist.py new file mode 100644 index 0000000..b4d003a --- /dev/null +++ b/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/manager.py b/manager.py new file mode 100644 index 0000000..af1e4a7 --- /dev/null +++ b/manager.py @@ -0,0 +1,114 @@ +import os +import sys +import signal +import copy +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 + self.config = Config.load(self.configfile) if os.path.exists(self.configfile) else Config() + + self.init_web(host, 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({}) # 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: + 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() + + self.display.update_config() + + if oldconfig.album_list != self.config.album_list: + self.update_textures() + else: + self.texture_list.max_cache_items = self.config.max_cache_assets + + # If all goes well + self.config.save(self.configfile) + return True + + +from lazycachelist import LazyCachingTextureList +from window import PixDisplay +from immich import ImmichConnector +from settings import Config @@ -0,0 +1,15 @@ +import argparse + +from flaskapi import app, socketio +from settings import Config +from manager import PixMan + + +if __name__ == "__main__": + 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() + + PixMan.initialize(args.config, app, socketio, args.host, args.port) diff --git a/renderer.py b/renderer.py new file mode 100644 index 0000000..ae4be12 --- /dev/null +++ b/renderer.py @@ -0,0 +1,169 @@ +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.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 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 + + @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): + 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) + + glutSwapBuffers() + + 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) + + glutSwapBuffers() + + +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/requirements.txt b/requirements.txt new file mode 100644 index 0000000..991c1ce --- /dev/null +++ b/requirements.txt @@ -0,0 +1,7 @@ +flask +flask-cors # DEBUG +flask-socketio +numpy +pillow +pyopengl +requests diff --git a/settings.py b/settings.py new file mode 100644 index 0000000..69e40ca --- /dev/null +++ b/settings.py @@ -0,0 +1,35 @@ +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) + + #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): + 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/texture.py b/texture.py new file mode 100644 index 0000000..3a56b5f --- /dev/null +++ b/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) @@ -0,0 +1,2 @@ +push current images data on new socket connection opening up +decrease size of assets in memory diff --git a/window.py b/window.py new file mode 100644 index 0000000..5387930 --- /dev/null +++ b/window.py @@ -0,0 +1,169 @@ +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 manager import PixMan + +class PixDisplay: + def __init__(self, textures): + 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 + + @property + def max_framerate(self): + #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 / max_fps) # In ms + + 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 + 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.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) + glutSwapBuffers() + 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 = glutGet(GLUT_WINDOW_WIDTH), glutGet(GLUT_WINDOW_HEIGHT) + + # 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 + + # Limit framerate + def timer(self, value): + glutPostRedisplay() + glutTimerFunc(self.frame_time, self.timer, 0) # Schedule next frame + + 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.seek(-1) + elif key == GLUT_KEY_RIGHT: + self.seek(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 + glutInit(glut_args) + glutInitDisplayMode(GLUT_DOUBLE | GLUT_RGB) + 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() + + # Set up the OpenGL viewport and projection + 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) + + # 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() + |
