Mercurial > code > home > repos > light9
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