changeset 2457:d94480bfb179

more work on blender time sync. Might be working, aside from blender play-button
author drewp@bigasterisk.com
date Tue, 20 May 2025 13:48:07 -0700
parents 917bc2eaf4f4
children 0e27ba33118c
files blender/time_sync/blender_time.py blender/time_sync/time_from_graph.py src/light9/ascoltami/graph_context.py src/light9/ascoltami/main.py src/light9/ascoltami/play_state.py
diffstat 5 files changed, 100 insertions(+), 49 deletions(-) [+]
line wrap: on
line diff
--- a/blender/time_sync/blender_time.py	Tue May 20 09:25:18 2025 -0700
+++ b/blender/time_sync/blender_time.py	Tue May 20 13:48:07 2025 -0700
@@ -5,6 +5,7 @@
 from bpy.app.handlers import persistent
 
 from light9.run_local import log
+from light9.ascoltami.play_state import AscoPlayState
 
 UPDATE_PERIOD = 1 / 20
 
@@ -31,12 +32,23 @@
     t: float
 
 
+@dataclass
+class BlenderPlayState:
+    isAnimationPlaying: bool
+    isScrubbing: bool
+
+
 class BlenderTime:
     """all methods to run in main thread"""
 
-    def __init__(self, onEvent: Callable[[_TimeEvent], None]):
+    def __init__(self, onEvent: Callable[[_TimeEvent], None], ascoltamiPlayStateRO: AscoPlayState):
+        self.ascoltamiPlayStateRO = ascoltamiPlayStateRO
         self._onEvent = onEvent
         self.lastSetFrame = -1
+        self.lastPlayState = BlenderPlayState(False, False)
+        self.loaded = False
+        self.curFrameDirty = True
+        self.durationDirty = True
 
     def start(self):
         bpy.app.handlers.load_post.append(self._on_load_post)
@@ -44,11 +56,13 @@
 
         # need persistent because `blender --addons ...` seems to start addon
         # before loading scene.
-        bpy.app.timers.register(self.update, persistent=True)
+        bpy.app.timers.register(self._update, persistent=True)
 
-        self.emitEvent(SceneLoaded())
+        self._emitEvent(SceneLoaded())
 
     def setSceneDuration(self, duration: float):
+        if not self.loaded:
+            return
         self.duration = duration
         scene = bpy.context.scene
         fps = scene.render.fps
@@ -56,6 +70,7 @@
         scene.frame_end = int(duration * fps)
 
     def frameDuration(self):
+        """press Home on the timeline to frame the whole range"""
         return
         # todo: need to be in screen context or something
         context_override = bpy.context.copy()
@@ -64,44 +79,55 @@
             bpy.ops.action.view_all()
 
     def setCurrentTime(self, t: float):
+        if not self.loaded:
+            return
         scene = bpy.context.scene
         fps = scene.render.fps
-        fr = int(clamp(t, 0, self.duration) * fps)
+        fr = int(clamp(t * fps, 0, scene.frame_end))
         self.lastSetFrame = fr
         scene.frame_set(fr)
 
-    def setBlenderTime(self, t: float, duration: float):
-        log.info(f'set blender time to {t:.2f}')
+    def setRange(self, duration: float):
         self.setSceneDuration(duration)
         self.frameDuration()
-        self.setCurrentTime(t)
 
-    def emitEvent(self, event: _TimeEvent):
-        log.info(f'🌹 emitEvent {event}')
+    def _emitEvent(self, event: _TimeEvent):
+        # log.info(f'🌹 emitEvent {event}')
         self._onEvent(event)
 
     @persistent
-    def update(self):
-        if 0:
-            if self.playing:
-                with self.lock:
-                    t = self.currentTime()
-                if t is not None:
-                    self.blenderTime.setBlenderTime(t, self.duration)
+    def _update(self):
+        if self.curFrameDirty or self.ascoltamiPlayStateRO.playing:
+            self._followAscoTime()
+            self.curFrameDirty=False
+        if self.durationDirty:
+            if (d := self.ascoltamiPlayStateRO.duration) is not None:
+                self.setRange(d)
+                self.durationDirty = False
+            # # todo: playing in blender should start ascoltami playback
+            # cur =  BlenderPlayState(bpy.context.screen.is_animation_playing, bpy.context.screen.is_scrubbing)
+
         return UPDATE_PERIOD
 
+    def _followAscoTime(self):
+        if (t := self.ascoltamiPlayStateRO.getCurrentSongTime()) is not None:
+            self.setCurrentTime(t)
+
     @persistent
     def _on_load_post(self, scene, deps):
-        self.emitEvent(SceneLoaded())
+        self._emitEvent(SceneLoaded())
+        self.loaded = True
+        self.curFrameDirty = True
 
     @persistent
     def _on_frame_change_post(self, scene, deps):
-        # if scene.frame_current == self.lastSetFrame:
-        #     return
+        # log.info(f' _on_frame_change_post  {self.lastPlayState}')
+        if scene.frame_current == self.lastSetFrame:
+            return
         # blender requested this frame change, either playing or paused (scrubbing timeline)
         self.lastSetFrame = scene.frame_current
         t = round(scene.frame_current / scene.render.fps, 3)
-        if bpy.context.screen.is_animation_playing and not bpy.context.screen.is_scrubbing:
-            self.emitEvent(PlayingGotoTime(t=t))
+        if self.lastPlayState.isAnimationPlaying and not self.lastPlayState.isScrubbing:
+            self._emitEvent(PlayingGotoTime(t=t))
         else:
-            self.emitEvent(PausedGotoTime(t=t))
+            self._emitEvent(PausedGotoTime(t=t))
--- a/blender/time_sync/time_from_graph.py	Tue May 20 09:25:18 2025 -0700
+++ b/blender/time_sync/time_from_graph.py	Tue May 20 13:48:07 2025 -0700
@@ -3,12 +3,14 @@
 import time
 from typing import Coroutine
 
+from attr import dataclass
 from rdfdb.patch import Patch
 from rdfdb.syncedgraph.syncedgraph import SyncedGraph
 from rdflib import Literal
 
 from light9 import networking, showconfig
 from light9.ascoltami.graph_context import ascoltamiContext
+from light9.ascoltami.play_state import AscoPlayState
 from light9.namespaces import L9
 from light9.newtypes import decimalLiteral
 from light9.run_local import log
@@ -24,23 +26,17 @@
 )
 
 
-
-
-
 class Sync:
     """asco is the authority on playback status. Sync maintains a copy of the state"""
     lock = threading.Lock()
 
-    # these are written ONLY by bg thread
-    duration: float = 1
-    wallStartTime: float | None = None
-    pausedSongTime: float | None = None
-    latestSongTime: float
-    playing = False
+    # this is edited ONLY by bg thread
+    ascoPlayState: AscoPlayState
 
     def __init__(self):
         # main thread
-        self.blenderTime = BlenderTime(self.onEvent)
+        self.ascoPlayState = AscoPlayState(None, None, False, False, 1.0)
+        self.blenderTime = BlenderTime(self.onBlenderEvent, self.ascoPlayState)
         self.blenderTime.start()
 
         self.ctx = ascoltamiContext(showconfig.showUri())
@@ -48,12 +44,12 @@
         self._loop = startLoopInThread(self.connectGraph())
         log.info('🚋10 Sync initd')
 
-    def onEvent(self, event: _TimeEvent):
+    def onBlenderEvent(self, event: _TimeEvent):
         # main thread
         match event:
             case SceneLoaded():
-                if hasattr(self, "latestSongTime"):
-                    self.blenderTime.setBlenderTime(self.latestSongTime, self.duration)
+                self.blenderTime.setRange(self.ascoPlayState.duration)
+                self.blenderTime.setCurrentTime(self.ascoPlayState.getCurrentSongTime() or 0.0)
             case PausedGotoTime(t):
                 self.runInBackgroundLoop(self.setInGraph(t, False))
             case PlayingGotoTime(t):
@@ -73,20 +69,18 @@
         # bg thread
         with self.lock:
             asco = L9['ascoltami']
-            self.wallStartTime = typedValue(float | None, self.graph, asco, L9['wallStartTime'])
-            self.pausedSongTime = typedValue(float | None, self.graph, asco, L9['pausedSongTime'])
-            self.duration = typedValue(float | None, self.graph, asco, L9['duration']) or 1.0
-            self.playing = typedValue(bool | None, self.graph, asco, L9['playing']) or False
-
-    def currentTime(self) -> float | None:
-        if self.wallStartTime is not None:
-            return time.time() - self.wallStartTime
-        if self.pausedSongTime is not None:
-            return self.pausedSongTime
-        log.warn('no time data')
-        return None
+            
+            self.ascoPlayState.wallStartTime = typedValue(float | None, self.graph, asco, L9['wallStartTime'])
+            self.ascoPlayState.pausedSongTime = typedValue(float | None, self.graph, asco, L9['pausedSongTime'])
+            self.ascoPlayState.duration = typedValue(float | None, self.graph, asco, L9['duration']) or 1.0
+            self.blenderTime.durationDirty = True # todo: called too often
+            self.ascoPlayState.playing = typedValue(bool | None, self.graph, asco, L9['playing']) or False
+            self.ascoPlayState.endOfSong = typedValue(bool | None, self.graph, asco, L9['endOfSong']) or False
+            log.info(f'🍇 syncFromGraph {self.ascoPlayState=}')
+            self.blenderTime.curFrameDirty = True
 
     async def setGraphPlaying(self, isBlenderPlaying: bool):
+        return
         # bg thread
         log.info(f'set graph playing to {isBlenderPlaying}')
         self.graph.patchObject(self.ctx, L9['ascoltami'], L9['playing'], Literal(isBlenderPlaying))
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/light9/ascoltami/graph_context.py	Tue May 20 13:48:07 2025 -0700
@@ -0,0 +1,7 @@
+
+from typing import cast
+from rdflib import URIRef
+from rdflib.graph import _ContextType
+
+def ascoltamiContext(show: URIRef):
+    return cast(_ContextType, (URIRef(show + '/ascoltami')))
--- a/src/light9/ascoltami/main.py	Tue May 20 09:25:18 2025 -0700
+++ b/src/light9/ascoltami/main.py	Tue May 20 13:48:07 2025 -0700
@@ -3,7 +3,7 @@
 
 from rdfdb.syncedgraph.syncedgraph import SyncedGraph
 from rdflib import ConjunctiveGraph, Literal, URIRef
-from rdflib.graph import _ContextType
+
 from starlette.applications import Starlette
 from starlette.routing import Route
 from starlette_exporter import PrometheusMiddleware, handle_metrics
@@ -16,6 +16,7 @@
 from light9.namespaces import L9
 from light9.newtypes import decimalLiteral
 from light9.run_local import log
+from light9.ascoltami.graph_context import ascoltamiContext
 
 
 class Ascoltami:
@@ -24,7 +25,7 @@
         self.graph = graph
         self.player = Player(onStateChange=self.onStateChange, autoStopOffset=0)
         self.show = show
-        self.ctx = cast(_ContextType, (URIRef(self.show + '/ascoltami')))
+        self.ctx = ascoltamiContext(show)
 
         self.playerState = PlayerState()
         self.playlist = Playlist(graph, show)
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/light9/ascoltami/play_state.py	Tue May 20 13:48:07 2025 -0700
@@ -0,0 +1,23 @@
+
+from dataclasses import dataclass
+import time
+from light9.run_local import log
+
+
+@dataclass
+class AscoPlayState:
+    # one mutable instance; modified by bg thread
+    wallStartTime: float | None
+    pausedSongTime: float | None
+    playing: bool
+    endOfSong: bool
+    duration: float
+
+    def getCurrentSongTime(self) -> float | None:
+        # bg OR main thread
+        if self.wallStartTime is not None:
+            return time.time() - self.wallStartTime
+        if self.pausedSongTime is not None:
+            return self.pausedSongTime
+        log.warn('no time data')
+        return None