Skip to content

Commit 06ebc96

Browse files
authored
Update postergeist.py
# Changelog All notable changes to **Postergeist** will be documented in this file. --- ## [1.3.0] - 2025-09-22 ### Added - **Animated Overlay Support** - Overlays can now be `.gif` and `.apng` files in addition to static images. - Frame-based animation support with correct duration handling. - **Overlay Behavior Enhancements** - Overlays suppressed automatically when posters/videos nearly fill the entire display. - Overlays animate independently of the slideshow. - **Performance Mode** - New `--performance-mode` flag disables heavy visual effects (like glow blur). - Optimized for low-powered devices such as Raspberry Pi. - **Glow Effect** - Posters now have an optional glow effect (Gaussian blur behind the poster). - **Cached Video Background** - Video backgrounds are blurred and cached for smoother playback. - **New CLI Options** - `--fade-height`: sets the fade height at the bottom of posters as a percentage (default: 20). - `--performance-mode`: disables glow and other intensive effects. ### Changed - **Overlay Handling** - Rewritten to support both static and animated overlays. - Uses separate scheduling for overlays (`overlay_job`) and slides (`slideshow_job`). - **Image Processing** - Split into `prepare_base_frame()` (background + poster) and `_update_canvas()` (final composition with overlays). - **Fade Transition Logic** - Now handled by `fade_to_new_slide()` for smoother overlay-aware blending. - **Rotation** - Rotating posters resets cached video backgrounds to prevent blur misalignment. ### Removed - Replaced the older single-pass overlay system with a more robust animated overlay management system. --- ## [1.2.0] - 2025-09-17 ### Added - **Initial slideshow engine** with image & video support. - **Overlay support** for static `.png`/`.jpg` images. - **Fade transitions** between slides. - **Rotation support** via keyboard shortcut. - **Multi-display support** with fullscreen or windowed mode. - **Randomized slide delay** option. - **Splash screen** when no media is available.
1 parent 0e74769 commit 06ebc96

File tree

1 file changed

+157
-64
lines changed

1 file changed

+157
-64
lines changed

postergeist.py

Lines changed: 157 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
import random
33
import argparse
44
import cv2
5-
from PIL import Image, ImageTk, ImageFilter
5+
from PIL import Image, ImageTk, ImageFilter, ImageSequence
66
import tkinter as tk
77
from screeninfo import get_monitors
88

@@ -12,26 +12,43 @@
1212
# -----------------------------------------
1313

1414
class Postergeist:
15-
FRAME_INTERVAL_MS = 1000 // 30 # Approx 30 FPS
15+
FRAME_INTERVAL_MS = 1000 // 30
1616

17-
def __init__(self, root, folder, overlay_folder, delay, random_delay, start_rotation, fade_duration=1000):
17+
def __init__(self, root, folder, overlay_folder, delay, random_delay, start_rotation, fade_duration=1000,
18+
fade_height=20, performance_mode=False):
1819
self.root = root
1920
self.folder = folder
2021
self.overlay_folder = overlay_folder
2122
self.delay = delay
2223
self.random_delay = random_delay
24+
self.fade_height_percent = fade_height
25+
# --- NEW ---
26+
self.performance_mode = performance_mode
2327

2428
self.files = self.load_files(folder)
2529
self.overlays = self.load_overlays(overlay_folder)
2630

2731
self.running = True
2832
self.video_capture = None
2933
self.tk_img = None
30-
self.current_pil_img = None
34+
35+
# --- State Management ---
36+
self.current_pil_img_no_overlay = None
37+
self.current_pil_img_final = None
3138
self.rotation = start_rotation
3239
self.index = 0
33-
self.after_id = None
34-
self.splash_text_id = None
40+
self.suppress_overlay = False
41+
self.cached_video_background = None
42+
43+
# --- Job Scheduling IDs ---
44+
self.slideshow_job = None
45+
self.overlay_job = None
46+
47+
# --- Overlay Animation State ---
48+
self.active_overlay_path = None
49+
self.active_overlay_frames = []
50+
self.active_overlay_index = 0
51+
self.active_overlay_duration = 100
3552

3653
self.fade_duration = fade_duration
3754
self.fade_steps = 20
@@ -62,7 +79,7 @@ def load_files(self, folder):
6279
return []
6380

6481
def load_overlays(self, overlay_folder):
65-
exts = (".png", ".jpg", ".jpeg", ".webp")
82+
exts = (".png", ".jpg", ".jpeg", ".webp", ".gif", ".apng")
6683
overlays = []
6784
if overlay_folder and os.path.exists(overlay_folder):
6885
for f in os.listdir(overlay_folder):
@@ -82,57 +99,87 @@ def refresh_files(self):
8299
self.shuffle_files()
83100
self.show_file()
84101

85-
def prepare_image_frame(self, img_to_process):
102+
def prepare_base_frame(self, img_to_process, is_video=False):
86103
screen_w = self.root.winfo_width()
87104
screen_h = self.root.winfo_height()
88-
89105
is_portrait_rot = self.rotation in [90, 270]
90106
canvas_w = screen_h if is_portrait_rot else screen_w
91107
canvas_h = screen_w if is_portrait_rot else screen_h
92108

93-
background = img_to_process.resize((canvas_w, canvas_h), Image.LANCZOS).filter(ImageFilter.GaussianBlur(40))
109+
if is_video and self.cached_video_background:
110+
background = self.cached_video_background.copy()
111+
else:
112+
background = img_to_process.resize((canvas_w, canvas_h), Image.LANCZOS).filter(ImageFilter.GaussianBlur(40))
113+
if is_video:
114+
self.cached_video_background = background.copy()
94115

95116
poster = img_to_process.copy()
96117
poster.thumbnail((canvas_w, canvas_h), Image.LANCZOS)
97-
poster_x = (canvas_w - poster.width) // 2
118+
p_w, p_h = poster.size
119+
120+
if p_h >= canvas_h * 0.99:
121+
self.suppress_overlay = True
122+
else:
123+
self.suppress_overlay = False
124+
# --- MODIFIED: Glow effect is skipped in performance mode ---
125+
if not self.performance_mode:
126+
glow = poster.filter(ImageFilter.GaussianBlur(25))
127+
glow_x = (canvas_w - p_w) // 2
128+
glow_y = 5
129+
background.paste(glow, (glow_x, glow_y), glow)
130+
131+
poster_x = (canvas_w - p_w) // 2
98132
poster_y = 0
99-
background.paste(poster, (poster_x, poster_y))
133+
background.paste(poster, (poster_x, poster_y), poster)
134+
self.current_pil_img_no_overlay = background
100135

101-
if self.overlays and poster.height < canvas_h:
102-
overlay_path = random.choice(self.overlays)
136+
def _update_canvas(self):
137+
if not self.current_pil_img_no_overlay:
138+
return
139+
img_with_overlay = self.current_pil_img_no_overlay.copy()
140+
if self.active_overlay_path and not self.suppress_overlay:
103141
try:
104-
with Image.open(overlay_path) as overlay_img:
105-
overlay = overlay_img.copy()
106-
overlay.thumbnail((canvas_w, canvas_h // 3), Image.LANCZOS)
107-
overlay_x = (canvas_w - overlay.width) // 2
108-
overlay_y = canvas_h - overlay.height
109-
if overlay.mode in ("RGBA", "LA"):
110-
background.paste(overlay, (overlay_x, overlay_y), overlay)
111-
else:
112-
background.paste(overlay, (overlay_x, overlay_y))
142+
overlay_img = None
143+
if self.active_overlay_frames:
144+
overlay_img = self.active_overlay_frames[self.active_overlay_index]
145+
else:
146+
overlay_img = Image.open(self.active_overlay_path)
147+
canvas_w = img_with_overlay.width
148+
canvas_h = img_with_overlay.height
149+
overlay = overlay_img.copy()
150+
overlay.thumbnail((canvas_w, canvas_h // 3), Image.LANCZOS)
151+
overlay_x = (canvas_w - overlay.width) // 2
152+
overlay_y = canvas_h - overlay.height
153+
if overlay.mode in ("RGBA", "LA"):
154+
img_with_overlay.paste(overlay, (overlay_x, overlay_y), overlay)
155+
else:
156+
img_with_overlay.paste(overlay, (overlay_x, overlay_y))
113157
except Exception as e:
114-
print(f"Error loading overlay {os.path.basename(overlay_path)}: {e}")
158+
print(f"Error processing overlay {os.path.basename(self.active_overlay_path)}: {e}")
159+
self.active_overlay_path = None
115160

116161
if self.rotation != 0:
117-
final_img = background.rotate(self.rotation, expand=True)
162+
final_img = img_with_overlay.rotate(self.rotation, expand=True)
118163
else:
119-
final_img = background
120-
121-
return final_img
164+
final_img = img_with_overlay
165+
self.current_pil_img_final = final_img
166+
self.tk_img = ImageTk.PhotoImage(final_img)
167+
self.canvas.delete("all")
168+
self.canvas.create_image(self.root.winfo_width() // 2, self.root.winfo_height() // 2, image=self.tk_img)
122169

123-
def fade_to_image(self, new_img, old_img):
124-
if old_img is None or self.fade_duration == 0:
125-
self.tk_img = ImageTk.PhotoImage(new_img)
126-
self.canvas.delete("all")
127-
self.canvas.create_image(self.root.winfo_width() // 2, self.root.winfo_height() // 2, image=self.tk_img)
170+
def fade_to_new_slide(self, new_base_img):
171+
old_final_img = self.current_pil_img_final
172+
self.current_pil_img_no_overlay = new_base_img
173+
self._update_canvas()
174+
new_final_img = self.current_pil_img_final
175+
if old_final_img is None or self.fade_duration == 0:
128176
return
129-
130177
step_delay = self.fade_duration // self.fade_steps
131178

132179
def do_step(step=0):
133180
if not self.running and step > 0: return
134181
alpha = step / self.fade_steps
135-
blended = Image.blend(old_img, new_img, alpha)
182+
blended = Image.blend(old_final_img, new_final_img, alpha)
136183
self.tk_img = ImageTk.PhotoImage(blended)
137184
self.canvas.delete("all")
138185
self.canvas.create_image(self.root.winfo_width() // 2, self.root.winfo_height() // 2, image=self.tk_img)
@@ -141,27 +188,57 @@ def do_step(step=0):
141188

142189
do_step()
143190

191+
def _select_new_overlay(self):
192+
self.active_overlay_path = None
193+
self.active_overlay_frames = []
194+
self.active_overlay_index = 0
195+
if self.overlays:
196+
self.active_overlay_path = random.choice(self.overlays)
197+
if self.active_overlay_path.lower().endswith((".gif", ".apng")):
198+
try:
199+
with Image.open(self.active_overlay_path) as img:
200+
self.active_overlay_duration = img.info.get('duration', 100)
201+
self.active_overlay_frames = [frame.copy() for frame in ImageSequence.Iterator(img)]
202+
except Exception as e:
203+
print(f"Error loading animated overlay {os.path.basename(self.active_overlay_path)}: {e}")
204+
self.active_overlay_path = None
205+
144206
def show_image(self, path):
145-
old_img = self.current_pil_img
207+
self.cached_video_background = None
208+
self._select_new_overlay()
146209
try:
147210
with Image.open(path) as img:
148-
img_rgb = img.convert("RGB")
149-
final_frame = self.prepare_image_frame(img_rgb)
150-
self.current_pil_img = final_frame
151-
self.fade_to_image(final_frame, old_img)
211+
img_rgba = img.convert("RGBA")
212+
self.prepare_base_frame(img_rgba, is_video=False)
213+
self.fade_to_new_slide(self.current_pil_img_no_overlay)
152214
except Exception as e:
153215
print(f"Error processing image '{os.path.basename(path)}': {e}. Skipping.")
154-
self.after_id = self.root.after(100, self.next_slide)
216+
self.slideshow_job = self.root.after(100, self.next_slide)
155217
return
156-
self.after_id = self.root.after(self.get_delay(), self.next_slide)
218+
if self.active_overlay_frames:
219+
self._animate_overlay()
220+
self.slideshow_job = self.root.after(self.get_delay(), self.next_slide)
157221

158222
def show_video(self, path):
223+
self.cached_video_background = None
159224
if self.video_capture: self.video_capture.release()
160225
self.video_capture = cv2.VideoCapture(path)
161226
if not self.video_capture.isOpened():
162227
print(f"Error opening video '{os.path.basename(path)}'. Skipping.")
163228
self.root.after(100, self.next_slide)
164229
return
230+
231+
vid_w = self.video_capture.get(cv2.CAP_PROP_FRAME_WIDTH)
232+
vid_h = self.video_capture.get(cv2.CAP_PROP_FRAME_HEIGHT)
233+
screen_w = self.root.winfo_width()
234+
screen_h = self.root.winfo_height()
235+
if vid_w < screen_w * 0.95 or vid_h < screen_h * 0.95:
236+
self._select_new_overlay()
237+
else:
238+
self.active_overlay_path = None
239+
self.active_overlay_frames = []
240+
if self.active_overlay_frames:
241+
self._animate_overlay()
165242
self.update_video_frame()
166243

167244
def update_video_frame(self):
@@ -170,35 +247,36 @@ def update_video_frame(self):
170247
if not ret:
171248
self.video_capture.release()
172249
self.video_capture = None
173-
self.after_id = self.root.after(self.get_delay(), self.next_slide)
250+
self.slideshow_job = self.root.after(self.get_delay(), self.next_slide)
174251
return
175-
176-
frame_rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
177-
img = Image.fromarray(frame_rgb)
178-
final_frame = self.prepare_image_frame(img)
179-
self.current_pil_img = final_frame
180-
self.tk_img = ImageTk.PhotoImage(final_frame)
181-
self.canvas.delete("all")
182-
self.canvas.create_image(self.root.winfo_width() // 2, self.root.winfo_height() // 2, image=self.tk_img)
252+
frame_rgba = cv2.cvtColor(frame, cv2.COLOR_BGR2RGBA)
253+
img = Image.fromarray(frame_rgba)
254+
self.prepare_base_frame(img, is_video=True)
255+
self._update_canvas()
183256
self.root.after(self.FRAME_INTERVAL_MS, self.update_video_frame)
184257

258+
def _animate_overlay(self):
259+
if not self.running or not self.active_overlay_frames: return
260+
self.active_overlay_index = (self.active_overlay_index + 1) % len(self.active_overlay_frames)
261+
self._update_canvas()
262+
self.overlay_job = self.root.after(self.active_overlay_duration, self._animate_overlay)
263+
185264
def get_delay(self):
186265
if self.random_delay:
187266
return random.randint(60, 300) * 1000
188267
else:
189268
return self.delay * 1000
190269

191-
def cancel_scheduled_next_slide(self):
192-
if self.after_id:
193-
self.root.after_cancel(self.after_id)
194-
self.after_id = None
270+
def cancel_scheduled_jobs(self):
271+
if self.slideshow_job:
272+
self.root.after_cancel(self.slideshow_job)
273+
if self.overlay_job:
274+
self.root.after_cancel(self.overlay_job)
275+
self.slideshow_job = None
276+
self.overlay_job = None
195277

196278
def show_file(self):
197-
if self.splash_text_id:
198-
self.root.unbind("<Configure>")
199-
self.splash_text_id = None
200-
201-
self.cancel_scheduled_next_slide()
279+
self.cancel_scheduled_jobs()
202280
if not self.files:
203281
self.show_splash()
204282
return
@@ -224,7 +302,10 @@ def toggle_pause(self):
224302
if self.running:
225303
self.next_slide(manual=True)
226304
else:
227-
self.cancel_scheduled_next_slide()
305+
self.cancel_scheduled_jobs()
306+
if self.video_capture:
307+
self.video_capture.release()
308+
self.video_capture = None
228309

229310
def exit_slideshow(self):
230311
self.running = False
@@ -233,7 +314,13 @@ def exit_slideshow(self):
233314

234315
def rotate_poster(self):
235316
self.rotation = (self.rotation + 90) % 360
236-
self.show_file()
317+
self.cached_video_background = None
318+
if self.video_capture and self.video_capture.isOpened():
319+
self.show_file()
320+
else:
321+
# Need to reload the static image to redraw it
322+
if self.files:
323+
self.show_image(self.files[self.index])
237324

238325
def show_splash(self):
239326
w = self.root.winfo_width()
@@ -262,12 +349,17 @@ def on_resize_splash(self, event):
262349
def main():
263350
parser = argparse.ArgumentParser(description="Postergeist Slideshow")
264351
parser.add_argument("folder", nargs="?", default="posters", help="Folder with posters/videos")
265-
parser.add_argument("--overlays", default="overlays", help="Folder with overlay images")
352+
parser.add_argument("--overlays", default="overlays", help="Folder with overlay images/gifs/apngs")
266353
parser.add_argument("--delay", type=int, default=300, help="Delay between slides in seconds")
267354
parser.add_argument("--random-delay", action="store_true", help="Enable random delay between 1–5 minutes")
268355
parser.add_argument("--display", type=str, default="1", help="Which display to use (1, 2, 'all')")
269356
parser.add_argument("--windowed", action="store_true", help="Run in a window")
270357
parser.add_argument("--rotate", type=int, default=0, choices=[0, 90, 180, 270], help="Starting rotation")
358+
parser.add_argument("--fade-height", type=int, default=20,
359+
help="Fade height at bottom of poster as a percentage (e.g., 20)")
360+
# --- NEW ARGUMENT ---
361+
parser.add_argument("--performance-mode", action="store_true",
362+
help="Disable intensive effects like glow for better performance")
271363
args = parser.parse_args()
272364

273365
if not os.path.exists(args.folder):
@@ -303,7 +395,8 @@ def main():
303395
root.overrideredirect(True)
304396
root.config(cursor="none")
305397

306-
Postergeist(root, args.folder, overlay_folder, args.delay, args.random_delay, args.rotate)
398+
Postergeist(root, args.folder, overlay_folder, args.delay, args.random_delay, args.rotate,
399+
fade_height=args.fade_height, performance_mode=args.performance_mode)
307400
root.mainloop()
308401

309402

0 commit comments

Comments
 (0)