/ scene.py
scene.py
1 """ 2 DreamTalk Scene - Fresh Architecture (v2.0) 3 4 Scene classes for DreamTalk animations. 5 No Sketch & Toon dependency - uses geometry-based stroke rendering. 6 7 Classes: 8 - Scene: Abstract base class for all scenes 9 - TwoDScene: 2D orthographic camera setup 10 - ThreeDScene: 3D perspective camera setup 11 - RenderSettings: Configure render output 12 """ 13 14 import importlib 15 import DreamTalk.animation.animation 16 importlib.reload(DreamTalk.animation.animation) 17 from DreamTalk.animation.animation import ScalarAnimation, VectorAnimation 18 from DreamTalk.animation.abstract_animators import ProtoAnimator, AnimationGroup 19 from DreamTalk.objects.camera_objects import TwoDCamera, ThreeDCamera, Observer 20 from abc import ABC, abstractmethod 21 from collections import defaultdict 22 from DreamTalk.constants import * 23 import c4d 24 import os 25 import inspect 26 from pprint import pprint 27 28 29 class Scene(ABC): 30 """ 31 Abstract base class for DreamTalk scenes. 32 33 All scenes use geometry-based stroke rendering and Python Generators. 34 No Sketch & Toon or XPresso dependencies. 35 36 Args: 37 resolution: Render resolution preset (default: "default") 38 alpha: Enable alpha channel (default: True) 39 save: Save render output (default: False) 40 """ 41 42 def __init__(self, resolution="default", alpha=True, save=False): 43 self.resolution = resolution 44 self.alpha = alpha 45 self.save = save 46 self.time_ini = None 47 self.time_fin = None 48 self.kill_old_document() 49 self.create_new_document() 50 self.set_scene_name() 51 self.insert_document() 52 self.setup_realtime_viewport() 53 self.clear_console() 54 self.set_camera() 55 self.construct() 56 self.set_interactive_render_region() 57 self.set_render_settings() 58 self.adjust_timeline() 59 60 def START(self): 61 """Mark the start time for timeline trimming.""" 62 self.time_ini = self.document.GetTime() 63 64 def STOP(self): 65 """Mark the stop time for timeline trimming.""" 66 self.time_fin = self.document.GetTime() 67 68 def adjust_timeline(self): 69 """Adjust timeline to START/STOP markers.""" 70 if self.time_ini is not None: 71 self.document[c4d.DOCUMENT_MINTIME] = self.time_ini 72 self.document[c4d.DOCUMENT_LOOPMINTIME] = self.time_ini 73 74 if self.time_fin is None: 75 self.document[c4d.DOCUMENT_MAXTIME] = self.document.GetTime() 76 self.document[c4d.DOCUMENT_LOOPMAXTIME] = self.document.GetTime() 77 else: 78 self.document[c4d.DOCUMENT_MAXTIME] = self.time_fin 79 self.document[c4d.DOCUMENT_LOOPMAXTIME] = self.time_fin 80 81 def set_render_settings(self): 82 """Configure render settings.""" 83 self.render_settings = RenderSettings(alpha=self.alpha) 84 self.render_settings.set_resolution(self.resolution) 85 if self.save: 86 self.render_settings.set_export_settings() 87 88 def set_camera(self): 89 """Override in subclass to set up camera.""" 90 pass 91 92 def create_new_document(self): 93 """Create a new C4D document.""" 94 self.document = c4d.documents.BaseDocument() 95 c4d.documents.InsertBaseDocument(self.document) 96 97 def kill_old_document(self): 98 """Remove any existing document.""" 99 old_document = c4d.documents.GetActiveDocument() 100 old_document.Remove() 101 c4d.documents.KillDocument(old_document) 102 103 def clear_console(self): 104 """Clear the Python console.""" 105 c4d.CallCommand(13957) 106 107 def construct(self): 108 """ 109 Construct the scene (Kronos domain). 110 111 Override this method OR unfold() to create objects and animations. 112 unfold() is the preferred name in canonical DreamTalk syntax. 113 """ 114 # Check if subclass defines unfold() - use that if available 115 if hasattr(self, 'unfold') and callable(getattr(self, 'unfold')) and \ 116 type(self).unfold is not Scene.unfold: 117 self.unfold() 118 else: 119 # Abstract - subclass must override either construct() or unfold() 120 pass 121 122 def unfold(self): 123 """ 124 Unfold the dream (Kronos domain). 125 126 The canonical name for the temporal sequence method. 127 Override this method to create objects and animations. 128 129 This is the preferred method name in DreamTalk syntax. 130 construct() is maintained for backward compatibility. 131 """ 132 pass 133 134 @property 135 def scene_name(self): 136 return self._scene_name 137 138 @scene_name.setter 139 def scene_name(self, name): 140 self._scene_name = name 141 142 def set_scene_name(self): 143 """Set scene name from class name.""" 144 self.scene_name = self.__class__.__name__ 145 self.document.SetDocumentName(self.scene_name) 146 147 def insert_document(self): 148 """Insert document into C4D.""" 149 c4d.documents.InsertBaseDocument(self.document) 150 151 def setup_realtime_viewport(self): 152 """ 153 Configure viewport for render-like real-time output. 154 155 Sets up: 156 - Black background (matches render output) 157 - No grid, horizon, world axis 158 - No handles, HUD, null objects 159 - Geometry remains visible (generators, polygons, splines) 160 161 This enables real-time preview that matches final render, 162 eliminating the need for slow renderer feedback during development. 163 """ 164 bd = self.document.GetActiveBaseDraw() 165 if not bd: 166 return 167 168 # === Set background and grid colors to black === 169 c4d.SetViewColor(c4d.VIEWCOLOR_C4DBACKGROUND, c4d.Vector(0, 0, 0)) 170 c4d.SetViewColor(c4d.VIEWCOLOR_C4DBACKGROUND_GRAD1, c4d.Vector(0, 0, 0)) 171 c4d.SetViewColor(c4d.VIEWCOLOR_C4DBACKGROUND_GRAD2, c4d.Vector(0, 0, 0)) 172 c4d.SetViewColor(c4d.VIEWCOLOR_GRID_MAJOR, c4d.Vector(0, 0, 0)) 173 c4d.SetViewColor(c4d.VIEWCOLOR_GRID_MINOR, c4d.Vector(0, 0, 0)) 174 c4d.SetViewColor(c4d.VIEWCOLOR_BASEGRID, c4d.Vector(0, 0, 0)) 175 c4d.SetViewColor(c4d.VIEWCOLOR_HORIZON, c4d.Vector(0, 0, 0)) 176 177 # === Disable visual aids, keep geometry visible === 178 bc = bd.GetDataInstance() 179 180 # Hide world/scene visual aids 181 bc[c4d.BASEDRAW_DISPLAYFILTER_GRID] = False 182 bc[c4d.BASEDRAW_DISPLAYFILTER_BASEGRID] = False 183 bc[c4d.BASEDRAW_DISPLAYFILTER_HORIZON] = False 184 bc[c4d.BASEDRAW_DISPLAYFILTER_WORLDAXIS] = False 185 bc[c4d.BASEDRAW_DISPLAYFILTER_HUD] = False 186 bc[c4d.BASEDRAW_DISPLAYFILTER_NULL] = False 187 bc[c4d.BASEDRAW_DISPLAYFILTER_CAMERA] = False 188 bc[c4d.BASEDRAW_DISPLAYFILTER_LIGHT] = False 189 bc[c4d.BASEDRAW_DISPLAYFILTER_JOINT] = False 190 bc[c4d.BASEDRAW_DISPLAYFILTER_DEFORMER] = False 191 bc[c4d.BASEDRAW_DISPLAYFILTER_FIELD] = False 192 193 # Keep object interaction handles visible (axis gizmo on selection) 194 bc[c4d.BASEDRAW_DISPLAYFILTER_HANDLES] = True 195 bc[c4d.BASEDRAW_DISPLAYFILTER_OBJECTHANDLES] = True 196 bc[c4d.BASEDRAW_DISPLAYFILTER_HIGHLIGHTING] = True 197 198 # Keep geometry visible 199 bc[c4d.BASEDRAW_DISPLAYFILTER_GENERATOR] = True 200 bc[c4d.BASEDRAW_DISPLAYFILTER_POLYGON] = True 201 bc[c4d.BASEDRAW_DISPLAYFILTER_SPLINE] = True 202 bc[c4d.BASEDRAW_DISPLAYFILTER_HYPERNURBS] = True 203 204 # Enable supersampling for smoother edges 205 # Options: 0=none, 2, 3, 4, 5, 8, 16 206 bc[c4d.BASEDRAW_DATA_SUPERSAMPLING] = 4 # 4x anti-aliasing 207 208 bd.SetData(bc) 209 c4d.EventAdd() 210 211 def set_interactive_render_region(self): 212 """Create IRR window for live preview.""" 213 c4d.CallCommand(600000022) # IRR script by ID 214 215 def feed_run_time(self, animations, run_time): 216 """Feed run time to animations.""" 217 for animation in animations: 218 animation.abs_run_time = run_time 219 220 def execute_animations(self, animations): 221 """Execute animations.""" 222 for animation in animations: 223 animation.execute() 224 225 def add_time(self, run_time): 226 """Advance timeline by run_time seconds.""" 227 time_ini = self.document.GetTime() 228 time_fin = time_ini + c4d.BaseTime(run_time) 229 self.document.SetTime(time_fin) 230 c4d.EventAdd() 231 232 def flatten(self, animations): 233 """Flatten animation groups.""" 234 animation_group = DreamTalk.animation.animation.AnimationGroup(*animations) 235 flattened_animations = animation_group.animations 236 return flattened_animations 237 238 def get_animation(self, animators): 239 """Extract animations from animators.""" 240 animations = [] 241 for animator in animators: 242 class_name = animator.__class__.__name__ 243 # Check VectorAnimation first (it has scalar_animations to extract) 244 if class_name == "VectorAnimation" or (hasattr(animator, 'scalar_animations') and animator.scalar_animations): 245 scalar_animations = animator.scalar_animations 246 animations += scalar_animations 247 continue 248 # AnimationGroup - check BEFORE 'animations' attribute since AnimationGroup has that attribute 249 elif class_name == "AnimationGroup": 250 animations.append(animator) 251 continue 252 # ProtoAnimator wraps animations (check after AnimationGroup to avoid mishandling) 253 elif hasattr(animator, 'animations'): 254 animation = animator.animations 255 if hasattr(animation, 'scalar_animations') and animation.scalar_animations: 256 scalar_animations = animation.scalar_animations 257 animations += scalar_animations 258 continue 259 else: 260 animations.append(animation) 261 continue 262 # Direct animation objects (ScalarAnimation, etc.) 263 elif hasattr(animator, 'execute'): 264 animations.append(animator) 265 continue 266 else: 267 print("Unknown animator input!", animator.__class__) 268 continue 269 return animations 270 271 def play(self, *animators, run_time=1): 272 """ 273 Play animations over run_time seconds. 274 275 Args: 276 *animators: Animation objects to play 277 run_time: Duration in seconds (default 1) 278 """ 279 animations = self.get_animation(animators) 280 flattened_animations = self.flatten(animations) 281 self.feed_run_time(flattened_animations, run_time) 282 self.execute_animations(flattened_animations) 283 self.add_time(run_time) 284 285 def set(self, *animators): 286 """Set animations instantly (2 frames).""" 287 self.play(*animators, run_time=2/FPS) 288 289 def wait(self, seconds=1): 290 """Wait without animations.""" 291 self.add_time(seconds) 292 293 def render_preview_frames(self, frames=None, output_dir=None, width=640, height=360): 294 """ 295 Render key frames to PNG files for AI-assisted iteration. 296 297 Args: 298 frames: List of frame numbers. If None, auto-samples 5 frames. 299 output_dir: Output directory. Defaults to /tmp/dreamtalk_preview/ 300 width: Preview width (default 640) 301 height: Preview height (default 360) 302 303 Returns: 304 List of paths to rendered PNG files. 305 """ 306 import tempfile 307 308 if output_dir is None: 309 output_dir = os.path.join(tempfile.gettempdir(), "dreamtalk_preview") 310 os.makedirs(output_dir, exist_ok=True) 311 312 if frames is None: 313 min_frame = int(self.document[c4d.DOCUMENT_MINTIME].GetFrame(FPS)) 314 max_frame = int(self.document[c4d.DOCUMENT_MAXTIME].GetFrame(FPS)) 315 if max_frame > min_frame: 316 frames = [ 317 min_frame, 318 min_frame + (max_frame - min_frame) // 4, 319 min_frame + (max_frame - min_frame) // 2, 320 min_frame + 3 * (max_frame - min_frame) // 4, 321 max_frame 322 ] 323 else: 324 frames = [min_frame] 325 326 rd = self.document.GetActiveRenderData() 327 original_width = rd[c4d.RDATA_XRES] 328 original_height = rd[c4d.RDATA_YRES] 329 original_format = rd[c4d.RDATA_FORMAT] 330 original_save = rd[c4d.RDATA_SAVEIMAGE] 331 original_alpha = rd[c4d.RDATA_ALPHACHANNEL] 332 333 rd[c4d.RDATA_XRES] = width 334 rd[c4d.RDATA_YRES] = height 335 rd[c4d.RDATA_FORMAT] = c4d.FILTER_PNG 336 rd[c4d.RDATA_SAVEIMAGE] = False 337 rd[c4d.RDATA_ALPHACHANNEL] = True 338 rd[c4d.RDATA_RENDERENGINE] = c4d.RDATA_RENDERENGINE_STANDARD 339 340 rendered_paths = [] 341 342 try: 343 for frame in frames: 344 self.document.SetTime(c4d.BaseTime(frame, FPS)) 345 self.document.ExecutePasses(None, True, True, True, c4d.BUILDFLAGS_NONE) 346 347 bmp = c4d.bitmaps.BaseBitmap() 348 if bmp.Init(width, height, 32) != c4d.IMAGERESULT_OK: 349 print(f"[DreamTalk] Failed to init bitmap for frame {frame}") 350 continue 351 352 render_flags = c4d.RENDERFLAGS_EXTERNAL | c4d.RENDERFLAGS_NODOCUMENTCLONE 353 result = c4d.documents.RenderDocument(self.document, rd.GetData(), bmp, render_flags) 354 355 if result != c4d.RENDERRESULT_OK: 356 print(f"[DreamTalk] Render failed for frame {frame}: {result}") 357 continue 358 359 output_path = os.path.join(output_dir, f"preview_{frame:04d}.png") 360 if bmp.Save(output_path, c4d.FILTER_PNG) == c4d.IMAGERESULT_OK: 361 rendered_paths.append(output_path) 362 print(f"[DreamTalk] Rendered frame {frame} -> {output_path}") 363 else: 364 print(f"[DreamTalk] Failed to save frame {frame}") 365 366 bmp.FlushAll() 367 368 finally: 369 rd[c4d.RDATA_XRES] = original_width 370 rd[c4d.RDATA_YRES] = original_height 371 rd[c4d.RDATA_FORMAT] = original_format 372 rd[c4d.RDATA_SAVEIMAGE] = original_save 373 rd[c4d.RDATA_ALPHACHANNEL] = original_alpha 374 c4d.EventAdd() 375 376 return rendered_paths 377 378 379 class RenderSettings: 380 """ 381 Configure render settings for DreamTalk scenes. 382 383 Uses Standard renderer only - no Sketch & Toon. 384 385 Args: 386 alpha: Enable alpha channel (default: True) 387 """ 388 389 def __init__(self, alpha=True): 390 self.alpha = alpha 391 self.document = c4d.documents.GetActiveDocument() 392 self.set_base_settings() 393 394 def set_export_settings(self): 395 """Configure export path and format.""" 396 directory = os.path.dirname(inspect.stack()[3].filename) 397 frame = inspect.currentframe().f_back 398 class_name = frame.f_locals.get('self', None).__class__.__name__ 399 if self.alpha: 400 path = os.path.join(directory, class_name + "_alpha", class_name) 401 else: 402 path = os.path.join(directory, class_name) 403 self.settings[c4d.RDATA_PATH] = path 404 if self.alpha: 405 self.settings[c4d.RDATA_ALPHACHANNEL] = True 406 self.settings[c4d.RDATA_SAVEIMAGE] = True 407 408 def set_base_settings(self): 409 """Set base render settings.""" 410 self.settings = self.document.GetActiveRenderData() 411 412 self.settings[c4d.RDATA_RENDERENGINE] = c4d.RDATA_RENDERENGINE_STANDARD 413 self.settings[c4d.RDATA_FRAMESEQUENCE] = 3 # preview range 414 if self.alpha: 415 self.settings[c4d.RDATA_FORMAT] = 1023671 # PNG 416 else: 417 self.settings[c4d.RDATA_FORMAT] = 1125 # MP4 418 self.settings[c4d.RDATA_ALPHACHANNEL] = False 419 self.settings[c4d.RDATA_SAVEIMAGE] = False 420 421 def set_resolution(self, resolution): 422 """ 423 Set render resolution. 424 425 Square presets (DreamTalk symbols - 1:1): 426 - "verylow": 270x270 427 - "low": 540x540 428 - "default": 1080x1080 429 - "high": 1440x1440 430 - "veryhigh": 2160x2160 431 432 Widescreen presets (DreamSong videos - 16:9): 433 - "wide_verylow": 640x360 434 - "wide_low": 1280x720 435 - "wide": 1920x1080 436 - "wide_high": 2560x1440 437 - "wide_veryhigh": 3840x2160 438 """ 439 resolutions = { 440 "verylow": (270, 270), 441 "low": (540, 540), 442 "default": (1080, 1080), 443 "high": (1440, 1440), 444 "veryhigh": (2160, 2160), 445 "wide_verylow": (640, 360), 446 "wide_low": (1280, 720), 447 "wide": (1920, 1080), 448 "wide_high": (2560, 1440), 449 "wide_veryhigh": (3840, 2160), 450 } 451 452 if resolution in resolutions: 453 w, h = resolutions[resolution] 454 self.settings[c4d.RDATA_XRES] = w 455 self.settings[c4d.RDATA_YRES] = h 456 457 458 class TwoDScene(Scene): 459 """2D scene with orthographic camera.""" 460 461 def set_camera(self): 462 self.camera = TwoDCamera() 463 bd = self.document.GetActiveBaseDraw() 464 bd.SetSceneCamera(self.camera.camera.obj) 465 466 467 class ThreeDScene(Scene): 468 """3D scene with perspective camera.""" 469 470 def set_camera(self): 471 self.camera = ThreeDCamera() 472 bd = self.document.GetActiveBaseDraw() 473 bd.SetSceneCamera(self.camera.camera.obj) 474 475 476 # ============================================================================= 477 # UNIFIED DREAM - Canonical DreamTalk Syntax 478 # ============================================================================= 479 480 class Dream(Scene): 481 """ 482 Where holons unfold through time (Kronos domain). 483 484 Dream is the unified scene class for all DreamTalk work. 485 There is no fundamental distinction between 2D and 3D - just different 486 observer configurations. 487 488 The observer can be configured for: 489 - 2D work: looking at XY plane, using pan() and zoom() 490 - 3D work: orbital controls with orbit(), dolly(), move_focus() 491 492 Args: 493 resolution: Render resolution preset (default: "default") 494 alpha: Enable alpha channel (default: True) 495 save: Save render output (default: False) 496 observer_theta: Initial elevation angle (default: 0 for 2D-style) 497 498 Example: 499 class MyDream(Dream): 500 def unfold(self): 501 circle = Circle(radius=100) 502 self.play(Create(circle), run_time=1) 503 504 # 2D-style navigation 505 self.play(self.observer.pan(x=100), run_time=0.5) 506 self.play(self.observer.zoom(factor=0.5), run_time=0.5) 507 508 # 3D-style navigation 509 self.play(self.observer.orbit(theta=PI/6), run_time=1) 510 """ 511 512 def __init__(self, observer_theta=0, **kwargs): 513 self._observer_theta = observer_theta 514 super().__init__(**kwargs) 515 516 def set_camera(self): 517 """Set up the unified observer.""" 518 self.observer = Observer(theta=self._observer_theta) 519 bd = self.document.GetActiveBaseDraw() 520 bd.SetSceneCamera(self.observer.camera.obj) 521 522 # Legacy alias 523 self.camera = self.observer 524 525 526 # ============================================================================= 527 # LEGACY ALIASES - For backward compatibility 528 # ============================================================================= 529 530 # TwoDScene and ThreeDScene are kept for backward compatibility 531 # New code should use Dream directly 532 533 TwoDDream = TwoDScene # Legacy 534 ThreeDDream = ThreeDScene # Legacy