/ 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