Merge branch 'blender-v4.0-release'
[blender-addons.git] / system_demo_mode / demo_mode.py
blobf20517d881420a984ec40e57e16bd0fb7db28d83
1 # SPDX-FileCopyrightText: 2011-2023 Blender Foundation
3 # SPDX-License-Identifier: GPL-2.0-or-later
5 """
6 Even though this is in a package this can run as a stand alone scripts.
8 # --- example usage
9 blender --python release/scripts/addons/system_demo_mode/demo_mode.py
11 looks for demo.py textblock or file in the same path as the blend:
12 # --- example
13 config = [
14 dict(anim_cycles=1, anim_render=False, anim_screen_switch=0.0, anim_time_max=10.0, anim_time_min=4.0, mode='AUTO', display_render=4.0, file='/l/19534_simplest_mesh_2.blend'),
15 dict(anim_cycles=1, anim_render=False, anim_screen_switch=0.0, anim_time_max=10.0, anim_time_min=4.0, mode='AUTO', display_render=4.0, file='/l/252_pivotConstraint_01.blend'),
17 # ---
18 /data/src/blender/lib/tests/rendering/
19 """
21 import bpy
22 import time
23 import os
25 from . import DEMO_CFG
27 # populate from script
28 global_config_files = []
30 global_config = dict(
31 anim_cycles=1,
32 anim_render=False,
33 anim_screen_switch=0.0,
34 anim_time_max=60.0,
35 anim_time_min=4.0,
36 mode='AUTO',
37 display_render=4.0,
40 # switch to the next file in 2 sec.
41 global_config_fallback = dict(
42 anim_cycles=1,
43 anim_render=False,
44 anim_screen_switch=0.0,
45 anim_time_max=60.0,
46 anim_time_min=4.0,
47 mode='AUTO',
48 display_render=4.0,
51 global_state = {
52 "init_time": 0.0,
53 "last_switch": 0.0,
54 "reset_anim": False,
55 "anim_cycles": 0, # count how many times we played the anim
56 "last_frame": -1,
57 "is_render": False,
58 "render_time": "", # time render was finished.
59 "timer": None,
60 "basedir": "", # demo.py is stored here
61 "demo_index": 0,
62 "exit": False,
66 # -----------------------------------------------------------------------------
67 # render handler - maintain "is_render"
69 def handle_render_clear():
70 for ls in (bpy.app.handlers.render_complete, bpy.app.handlers.render_cancel):
71 while handle_render_done_cb in ls:
72 ls.remove(handle_render_done_cb)
75 def handle_render_done_cb(self):
76 global_state["is_render"] = True
79 def handle_render_init():
80 handle_render_clear()
81 bpy.app.handlers.render_complete.append(handle_render_done_cb)
82 bpy.app.handlers.render_cancel.append(handle_render_done_cb)
83 global_state["is_render"] = False
86 def demo_mode_auto_select():
88 play_area = 0
89 render_area = 0
91 totimg = 0
93 for area in bpy.context.window.screen.areas:
94 size = area.width * area.height
95 if area.type in {'VIEW_3D', 'GRAPH_EDITOR', 'DOPESHEET_EDITOR', 'NLA_EDITOR', 'TIMELINE'}:
96 play_area += size
97 elif area.type in {'IMAGE_EDITOR', 'SEQUENCE_EDITOR', 'NODE_EDITOR'}:
98 render_area += size
100 if area.type == 'IMAGE_EDITOR':
101 totimg += 1
103 # since our test files have this as defacto standard
104 scene = bpy.context.scene
105 if totimg >= 2 and (scene.camera or scene.render.use_sequencer):
106 mode = 'RENDER'
107 else:
108 if play_area >= render_area:
109 mode = 'PLAY'
110 else:
111 mode = 'RENDER'
113 if 0:
114 return 'PLAY'
116 return mode
119 def demo_mode_next_file(step=1):
121 # support for temp
122 if global_config_files[global_state["demo_index"]].get("is_tmp"):
123 del global_config_files[global_state["demo_index"]]
124 global_state["demo_index"] -= 1
126 demo_index_next = (global_state["demo_index"] + step) % len(global_config_files)
128 if global_state["exit"] and step > 0:
129 # check if we cycled
130 if demo_index_next < global_state["demo_index"]:
131 import sys
132 sys.exit(0)
134 global_state["demo_index"] = demo_index_next
135 print(global_state["demo_index"], "....")
136 print("func:demo_mode_next_file", global_state["demo_index"], "of", len(global_config_files))
137 filepath = global_config_files[global_state["demo_index"]]["file"]
138 bpy.ops.wm.open_mainfile(filepath=filepath)
141 def demo_mode_timer_add():
142 global_state["timer"] = bpy.context.window_manager.event_timer_add(0.8, window=bpy.context.window)
145 def demo_mode_timer_remove():
146 if global_state["timer"]:
147 bpy.context.window_manager.event_timer_remove(global_state["timer"])
148 global_state["timer"] = None
151 def demo_mode_load_file():
152 """ Take care, this can only do limited functions since its running
153 before the file is fully loaded.
154 Some operators will crash like playing an animation.
156 print("func:demo_mode_load_file")
157 DemoMode.first_run = True
158 bpy.ops.wm.demo_mode('EXEC_DEFAULT')
161 def demo_mode_temp_file():
162 """ Initialize a temp config for the duration of the play time.
163 Use this so we can initialize the demo intro screen but not show again.
165 assert (global_state["demo_index"] == 0)
167 temp_config = global_config_fallback.copy()
168 temp_config["anim_time_min"] = 0.0
169 temp_config["anim_time_max"] = 60.0
170 temp_config["anim_cycles"] = 0 # ensures we switch when hitting the end
171 temp_config["mode"] = 'PLAY'
172 temp_config["is_tmp"] = True
174 global_config_files.insert(0, temp_config)
177 def demo_mode_init():
178 print("func:demo_mode_init")
179 DemoKeepAlive.ensure()
181 if 1:
182 global_config.clear()
183 global_config.update(global_config_files[global_state["demo_index"]])
185 print(global_config)
187 demo_mode_timer_add()
189 if global_config["mode"] == 'AUTO':
190 global_config["mode"] = demo_mode_auto_select()
192 if global_config["mode"] == 'PLAY':
193 global_state["last_frame"] = -1
194 global_state["anim_cycles"] = 0
195 bpy.ops.screen.animation_play()
197 elif global_config["mode"] == 'RENDER':
198 print(" render")
200 # setup scene.
201 scene = bpy.context.scene
202 scene.render.filepath = "TEMP_RENDER"
203 scene.render.image_settings.file_format = 'AVI_JPEG' if global_config["anim_render"] else 'PNG'
204 scene.render.use_file_extension = False
205 scene.render.use_placeholder = False
206 try:
207 # XXX - without this rendering will crash because of a bug in blender!
208 bpy.ops.wm.redraw_timer(type='DRAW_WIN_SWAP', iterations=1)
209 if global_config["anim_render"]:
210 bpy.ops.render.render('INVOKE_DEFAULT', animation=True)
211 else:
212 bpy.ops.render.render('INVOKE_DEFAULT') # write_still=True, no need to write now.
214 handle_render_init()
216 except RuntimeError: # no camera for eg:
217 import traceback
218 traceback.print_exc()
220 else:
221 raise Exception("Unsupported mode %r" % global_config["mode"])
223 global_state["init_time"] = global_state["last_switch"] = time.time()
224 global_state["render_time"] = -1.0
227 def demo_mode_update():
228 time_current = time.time()
229 time_delta = time_current - global_state["last_switch"]
230 time_total = time_current - global_state["init_time"]
232 # --------------------------------------------------------------------------
233 # ANIMATE MODE
234 if global_config["mode"] == 'PLAY':
235 frame = bpy.context.scene.frame_current
236 # check for exit
237 if time_total > global_config["anim_time_max"]:
238 demo_mode_next_file()
239 return
240 # above cycles and minimum display time
241 if (
242 (time_total > global_config["anim_time_min"]) and
243 (global_state["anim_cycles"] > global_config["anim_cycles"])
245 # looped enough now.
246 demo_mode_next_file()
247 return
249 # run update funcs
250 if global_state["reset_anim"]:
251 global_state["reset_anim"] = False
252 bpy.ops.screen.animation_cancel(restore_frame=False)
253 bpy.ops.screen.animation_play()
255 # warning, switching the screen can switch the scene
256 # and mess with our last-frame/cycles counting.
257 if global_config["anim_screen_switch"]:
258 # print(time_delta, 1)
259 if time_delta > global_config["anim_screen_switch"]:
260 window = bpy.context.window
261 scene = window.scene
262 workspace = window.workspace
263 index = bpy.data.workspaces.keys().index(workspace.name)
264 workspace_new = bpy.data.workspaces[(index + 1) % len(bpy.data.workspaces)]
265 window.workspace = workspace_new
267 global_state["last_switch"] = time_current
269 # If we also switch scenes then reset last frame
270 # otherwise it could mess up cycle calculation.
271 if scene != window.scene:
272 global_state["last_frame"] = -1
274 # if global_config["mode"] == 'PLAY':
275 if 1:
276 global_state["reset_anim"] = True
278 # did we loop?
279 if global_state["last_frame"] > frame:
280 print("Cycle!")
281 global_state["anim_cycles"] += 1
283 global_state["last_frame"] = frame
285 # --------------------------------------------------------------------------
286 # RENDER MODE
287 elif global_config["mode"] == 'RENDER':
288 if global_state["is_render"]:
289 # wait until the time has passed
290 # XXX, todo, if rendering an anim we need some way to check its done.
291 if global_state["render_time"] == -1.0:
292 global_state["render_time"] = time.time()
293 else:
294 if time.time() - global_state["render_time"] > global_config["display_render"]:
295 handle_render_clear()
296 demo_mode_next_file()
297 return
298 else:
299 raise Exception("Unsupported mode %r" % global_config["mode"])
301 # -----------------------------------------------------------------------------
302 # modal operator
305 class DemoKeepAlive:
306 secret_attr = "_keepalive"
308 @staticmethod
309 def ensure():
310 if DemoKeepAlive.secret_attr not in bpy.app.driver_namespace:
311 bpy.app.driver_namespace[DemoKeepAlive.secret_attr] = DemoKeepAlive()
313 @staticmethod
314 def remove():
315 if DemoKeepAlive.secret_attr in bpy.app.driver_namespace:
316 del bpy.app.driver_namespace[DemoKeepAlive.secret_attr]
318 def __del__(self):
319 """ Hack, when the file is loaded the drivers namespace is cleared.
321 if DemoMode.enabled:
322 demo_mode_load_file()
325 class DemoMode(bpy.types.Operator):
326 bl_idname = "wm.demo_mode"
327 bl_label = "Demo"
329 enabled = False
330 first_run = True
332 def cleanup(self, disable=False):
333 demo_mode_timer_remove()
334 DemoMode.first_run = True
336 if disable:
337 DemoMode.enabled = False
338 DemoKeepAlive.remove()
340 def modal(self, context, event):
341 # print("DemoMode.modal", global_state["anim_cycles"])
342 if not DemoMode.enabled:
343 self.cleanup(disable=True)
344 return {'CANCELLED'}
346 if event.type == 'ESC':
347 self.cleanup(disable=True)
348 # disable here and not in cleanup because this is a user level disable.
349 # which should stay disabled until explicitly enabled again.
350 return {'CANCELLED'}
352 # print(event.type)
353 if DemoMode.first_run:
354 DemoMode.first_run = False
356 demo_mode_init()
357 else:
358 demo_mode_update()
360 return {'PASS_THROUGH'}
362 def execute(self, context):
363 print("func:DemoMode.execute:", len(global_config_files), "files")
365 use_temp = False
367 # load config if not loaded
368 if not global_config_files:
369 load_config()
370 use_temp = True
372 if not global_config_files:
373 self.report(
374 {'INFO'},
375 "No configuration found with text or file: %s. Run File -> Demo Mode Setup" %
376 DEMO_CFG,
378 return {'CANCELLED'}
380 if use_temp:
381 demo_mode_temp_file() # play this once through then never again
383 # toggle
384 if DemoMode.enabled and DemoMode.first_run is False:
385 # this actually cancells the previous running instance
386 # should never happen now, DemoModeControl is for this.
387 return {'CANCELLED'}
388 else:
389 DemoMode.enabled = True
391 context.window_manager.modal_handler_add(self)
392 return {'RUNNING_MODAL'}
394 def cancel(self, context):
395 print("func:DemoMode.cancel")
396 # disable here means no running on file-load.
397 self.cleanup()
399 # call from DemoModeControl
400 @classmethod
401 def disable(cls):
402 if cls.enabled and cls.first_run is False:
403 # this actually cancells the previous running instance
404 # should never happen now, DemoModeControl is for this.
405 cls.enabled = False
408 class DemoModeControl(bpy.types.Operator):
409 bl_idname = "wm.demo_mode_control"
410 bl_label = "Control"
412 mode: bpy.props.EnumProperty(
413 items=(
414 ('PREV', "Prev", ""),
415 ('PAUSE', "Pause", ""),
416 ('NEXT', "Next", ""),
418 name="Mode"
421 def execute(self, context):
422 mode = self.mode
423 if mode == 'PREV':
424 demo_mode_next_file(-1)
425 elif mode == 'NEXT':
426 demo_mode_next_file(1)
427 else: # pause
428 DemoMode.disable()
429 return {'FINISHED'}
432 def menu_func(self, context):
433 # print("func:menu_func - DemoMode.enabled:", DemoMode.enabled, "bpy.app.driver_namespace:", DemoKeepAlive.secret_attr not in bpy.app.driver_namespace, 'global_state["timer"]:', global_state["timer"])
434 layout = self.layout
435 layout.operator_context = 'EXEC_DEFAULT'
436 row = layout.row(align=True)
437 row.label(text="Demo Mode:")
438 if not DemoMode.enabled:
439 row.operator("wm.demo_mode", icon='PLAY', text="")
440 else:
441 row.operator("wm.demo_mode_control", icon='REW', text="").mode = 'PREV'
442 row.operator("wm.demo_mode_control", icon='PAUSE', text="").mode = 'PAUSE'
443 row.operator("wm.demo_mode_control", icon='FF', text="").mode = 'NEXT'
446 def register():
447 bpy.utils.register_class(DemoMode)
448 bpy.utils.register_class(DemoModeControl)
449 bpy.types.INFO_HT_header.append(menu_func)
452 def unregister():
453 bpy.utils.unregister_class(DemoMode)
454 bpy.utils.unregister_class(DemoModeControl)
455 bpy.types.INFO_HT_header.remove(menu_func)
458 # -----------------------------------------------------------------------------
459 # parse args
461 def load_config(cfg_name=DEMO_CFG):
462 namespace = {}
463 global_config_files.clear()
464 basedir = os.path.dirname(bpy.data.filepath)
466 text = bpy.data.texts.get(cfg_name)
467 if text is None:
468 demo_path = os.path.join(basedir, cfg_name)
469 if os.path.exists(demo_path):
470 print("Using config file: %r" % demo_path)
471 demo_file = open(demo_path, "r")
472 demo_data = demo_file.read()
473 demo_file.close()
474 else:
475 demo_data = ""
476 else:
477 print("Using config textblock: %r" % cfg_name)
478 demo_data = text.as_string()
479 demo_path = os.path.join(bpy.data.filepath, cfg_name) # fake
481 if not demo_data:
482 print("Could not find %r textblock or %r file." % (DEMO_CFG, demo_path))
483 return False
485 namespace["__file__"] = demo_path
487 exec(demo_data, namespace, namespace)
489 demo_config = namespace["config"]
490 demo_search_path = namespace.get("search_path")
491 global_state["exit"] = namespace.get("exit", False)
493 if demo_search_path is None:
494 print("reading: %r, no search_path found, missing files wont be searched." % demo_path)
495 if demo_search_path.startswith("//"):
496 demo_search_path = bpy.path.abspath(demo_search_path)
497 if not os.path.exists(demo_search_path):
498 print("reading: %r, search_path %r does not exist." % (demo_path, demo_search_path))
499 demo_search_path = None
501 blend_lookup = {}
502 # initialize once, case insensitive dict
504 def lookup_file(filepath):
505 filename = os.path.basename(filepath).lower()
507 if not blend_lookup:
508 # ensure only ever run once.
509 blend_lookup[None] = None
511 def blend_dict_items(path):
512 for dirpath, dirnames, filenames in os.walk(path):
513 # skip '.git'
514 dirnames[:] = [d for d in dirnames if not d.startswith(".")]
515 for filename in filenames:
516 if filename.lower().endswith(".blend"):
517 filepath = os.path.join(dirpath, filename)
518 yield (filename.lower(), filepath)
520 blend_lookup.update(dict(blend_dict_items(demo_search_path)))
522 # fallback to original file
523 return blend_lookup.get(filename, filepath)
524 # done with search lookup
526 for filecfg in demo_config:
527 filepath_test = filecfg["file"]
528 if not os.path.exists(filepath_test):
529 filepath_test = os.path.join(basedir, filecfg["file"])
530 if not os.path.exists(filepath_test):
531 filepath_test = lookup_file(filepath_test) # attempt to get from searchpath
532 if not os.path.exists(filepath_test):
533 print("Can't find %r or %r, skipping!")
534 continue
536 filecfg["file"] = os.path.normpath(filepath_test)
538 # sanitize
539 filecfg["file"] = os.path.abspath(filecfg["file"])
540 filecfg["file"] = os.path.normpath(filecfg["file"])
541 print(" Adding: %r" % filecfg["file"])
542 global_config_files.append(filecfg)
544 print("found %d files" % len(global_config_files))
546 global_state["basedir"] = basedir
548 return bool(global_config_files)
551 # support direct execution
552 if __name__ == "__main__":
553 register()
555 demo_mode_load_file() # kick starts the modal operator