22import random
33import argparse
44import cv2
5- from PIL import Image , ImageTk , ImageFilter
5+ from PIL import Image , ImageTk , ImageFilter , ImageSequence
66import tkinter as tk
77from screeninfo import get_monitors
88
1212# -----------------------------------------
1313
1414class 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):
262349def 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