278 lines
11 KiB
Python
278 lines
11 KiB
Python
"""
|
|
Local animations
|
|
|
|
/524 load_local_anim
|
|
assuming you loaded something.anim
|
|
/524 start_local_anim something
|
|
/524 stop_local_anim something
|
|
/524 save_local_anim something
|
|
|
|
If you want to trigger the animation from an object to simulate llStartAnimation():
|
|
llOwnerSay("@start_local_anim:something=force");
|
|
|
|
Also includes a concept of "anim manglers" similar to the "mesh manglers" of the
|
|
local mesh addon. This is useful if you want to test making procedural changes
|
|
to animations before uploading them. The manglers will be applied to any uploaded
|
|
animations as well.
|
|
|
|
May also be useful if you need to make ad-hoc changes to a bunch of animations on
|
|
bulk upload, like changing priority or removing a joint.
|
|
"""
|
|
|
|
import asyncio
|
|
import pathlib
|
|
from abc import abstractmethod
|
|
from typing import *
|
|
|
|
from hippolyzer.lib.base import serialization as se
|
|
from hippolyzer.lib.base.datatypes import UUID
|
|
from hippolyzer.lib.base.helpers import get_mtime
|
|
from hippolyzer.lib.base.llanim import Animation
|
|
from hippolyzer.lib.base.message.message import Block, Message
|
|
from hippolyzer.lib.proxy import addon_ctx
|
|
from hippolyzer.lib.proxy.addons import AddonManager
|
|
from hippolyzer.lib.proxy.addon_utils import BaseAddon, SessionProperty, GlobalProperty, show_message
|
|
from hippolyzer.lib.proxy.commands import handle_command
|
|
from hippolyzer.lib.proxy.http_asset_repo import HTTPAssetRepo
|
|
from hippolyzer.lib.proxy.http_flow import HippoHTTPFlow
|
|
from hippolyzer.lib.proxy.region import ProxiedRegion
|
|
from hippolyzer.lib.proxy.sessions import Session, SessionManager
|
|
|
|
|
|
class LocalAnimAddon(BaseAddon):
|
|
# name -> path, only for anims actually from files
|
|
local_anim_paths: Dict[str, str] = SessionProperty(dict)
|
|
# name -> anim bytes
|
|
local_anim_bytes: Dict[str, bytes] = SessionProperty(dict)
|
|
# name -> mtime or None. Only for anims from files.
|
|
local_anim_mtimes: Dict[str, Optional[float]] = SessionProperty(dict)
|
|
# name -> current asset ID (changes each play)
|
|
local_anim_playing_ids: Dict[str, UUID] = SessionProperty(dict)
|
|
anim_manglers: List[Callable[[Animation], Animation]] = GlobalProperty(list)
|
|
|
|
def handle_init(self, session_manager: SessionManager):
|
|
self.remangle_local_anims(session_manager)
|
|
|
|
def handle_session_init(self, session: Session):
|
|
# Reload anims and reload any manglers if we have any
|
|
self._schedule_task(self._try_reload_anims(session))
|
|
|
|
@handle_command()
|
|
async def load_local_anim(self, _session: Session, _region: ProxiedRegion):
|
|
"""Load a local animation file into the list of local anims"""
|
|
filename = await AddonManager.UI.open_file(filter_str="SL Anim (*.anim)")
|
|
if filename:
|
|
p = pathlib.Path(filename)
|
|
self.local_anim_paths[p.stem] = filename
|
|
|
|
@handle_command(anim_name=str)
|
|
async def start_local_anim(self, session: Session, region: ProxiedRegion, anim_name):
|
|
"""
|
|
Start a named local animation
|
|
|
|
Assuming you loaded an animation named something.anim:
|
|
start_local_anim something
|
|
"""
|
|
self.apply_local_anim_from_file(session, region, anim_name)
|
|
|
|
@handle_command(anim_name=str)
|
|
async def stop_local_anim(self, session: Session, region: ProxiedRegion, anim_name):
|
|
"""Stop a named local animation"""
|
|
self.apply_local_anim(session, region, anim_name, new_data=None)
|
|
|
|
@handle_command(anim_name=str)
|
|
async def save_local_anim(self, _session: Session, _region: ProxiedRegion, anim_name: str):
|
|
"""Save a named local anim to disk"""
|
|
anim_bytes = self.local_anim_bytes.get(anim_name)
|
|
if not anim_bytes:
|
|
return
|
|
filename = await AddonManager.UI.save_file(filter_str="SL Anim (*.anim)", default_suffix="anim")
|
|
if not filename:
|
|
return
|
|
with open(filename, "wb") as f:
|
|
f.write(anim_bytes)
|
|
|
|
async def _try_reload_anims(self, session: Session):
|
|
while True:
|
|
region = session.main_region
|
|
if not region:
|
|
await asyncio.sleep(1.0)
|
|
continue
|
|
|
|
# Loop over local anims we loaded
|
|
for anim_name, anim_id in self.local_anim_paths.items():
|
|
anim_id = self.local_anim_playing_ids.get(anim_name)
|
|
if not anim_id:
|
|
continue
|
|
# is playing right now, check if there's a newer version
|
|
self.apply_local_anim_from_file(session, region, anim_name, only_if_changed=True)
|
|
await asyncio.sleep(1.0)
|
|
|
|
def handle_rlv_command(self, session: Session, region: ProxiedRegion, source: UUID,
|
|
cmd: str, options: List[str], param: str):
|
|
# We only handle commands
|
|
if param != "force":
|
|
return
|
|
|
|
if cmd == "stop_local_anim":
|
|
self.apply_local_anim(session, region, options[0], new_data=None)
|
|
return True
|
|
elif cmd == "start_local_anim":
|
|
self.apply_local_anim_from_file(session, region, options[0])
|
|
return True
|
|
|
|
@classmethod
|
|
def apply_local_anim(cls, session: Session, region: ProxiedRegion,
|
|
anim_name: str, new_data: Optional[bytes] = None):
|
|
asset_repo: HTTPAssetRepo = session.session_manager.asset_repo
|
|
next_id: Optional[UUID] = None
|
|
new_msg = Message(
|
|
"AgentAnimation",
|
|
Block(
|
|
"AgentData",
|
|
AgentID=session.agent_id,
|
|
SessionID=session.id,
|
|
),
|
|
)
|
|
|
|
# Stop any old version of the anim that might be playing first
|
|
cur_id = cls.local_anim_playing_ids.get(anim_name)
|
|
if cur_id:
|
|
new_msg.add_block(Block(
|
|
"AnimationList",
|
|
AnimID=cur_id,
|
|
StartAnim=False,
|
|
))
|
|
|
|
if new_data is not None:
|
|
# Create a temp asset ID for the new version and send out a start request
|
|
next_id = asset_repo.create_asset(new_data, one_shot=True)
|
|
new_msg.add_block(Block(
|
|
"AnimationList",
|
|
AnimID=next_id,
|
|
StartAnim=True,
|
|
))
|
|
cls.local_anim_playing_ids[anim_name] = next_id
|
|
cls.local_anim_bytes[anim_name] = new_data
|
|
else:
|
|
# No data means just stop the anim
|
|
cls.local_anim_playing_ids.pop(anim_name, None)
|
|
cls.local_anim_bytes.pop(anim_name, None)
|
|
|
|
region.circuit.send(new_msg)
|
|
print(f"Changing {anim_name} to {next_id}")
|
|
|
|
@classmethod
|
|
def apply_local_anim_from_file(cls, session: Session, region: ProxiedRegion,
|
|
anim_name: str, only_if_changed=False):
|
|
anim_path = cls.local_anim_paths.get(anim_name)
|
|
anim_data = None
|
|
if anim_path:
|
|
old_mtime = cls.local_anim_mtimes.get(anim_name)
|
|
mtime = get_mtime(anim_path)
|
|
if only_if_changed and old_mtime == mtime:
|
|
return
|
|
|
|
cls.local_anim_mtimes[anim_name] = mtime
|
|
# file might not even exist anymore if mtime is `None`,
|
|
# anim will automatically stop if that happens.
|
|
if mtime:
|
|
if only_if_changed:
|
|
print(f"Re-applying {anim_name}")
|
|
else:
|
|
print(f"Playing {anim_name}")
|
|
|
|
with open(anim_path, "rb") as f:
|
|
anim_data = f.read()
|
|
anim_data = cls._mangle_anim(anim_data)
|
|
else:
|
|
print(f"Unknown anim {anim_name!r}")
|
|
cls.apply_local_anim(session, region, anim_name, new_data=anim_data)
|
|
|
|
@classmethod
|
|
def _mangle_anim(cls, anim_data: bytes) -> bytes:
|
|
if not cls.anim_manglers:
|
|
return anim_data
|
|
reader = se.BufferReader("<", anim_data)
|
|
spec = se.Dataclass(Animation)
|
|
anim = reader.read(spec)
|
|
for mangler in cls.anim_manglers:
|
|
anim = mangler(anim)
|
|
writer = se.BufferWriter("<")
|
|
writer.write(spec, anim)
|
|
return writer.copy_buffer()
|
|
|
|
@classmethod
|
|
def remangle_local_anims(cls, session_manager: SessionManager):
|
|
# Anim manglers are global, so we need to re-mangle anims for all sessions
|
|
for session in session_manager.sessions:
|
|
# Push the context of this session onto the stack so we can access
|
|
# session-scoped properties
|
|
with addon_ctx.push(new_session=session, new_region=session.main_region):
|
|
cls.local_anim_mtimes.clear()
|
|
|
|
def handle_http_request(self, session_manager: SessionManager, flow: HippoHTTPFlow):
|
|
if flow.name == "NewFileAgentInventoryUploader":
|
|
# Don't bother looking at this if we have no manglers
|
|
if not self.anim_manglers:
|
|
return
|
|
# This is kind of a crappy match but these magic bytes shouldn't match anything that SL
|
|
# allows as an upload type but animations.
|
|
if not flow.request.content or not flow.request.content.startswith(b"\x01\x00\x00\x00"):
|
|
return
|
|
|
|
# Replace the uploaded anim with the mangled version
|
|
flow.request.content = self._mangle_anim(flow.request.content)
|
|
show_message("Mangled upload request")
|
|
|
|
|
|
class BaseAnimManglerAddon(BaseAddon):
|
|
"""Base class for addons that mangle uploaded or file-based local animations"""
|
|
ANIM_MANGLERS: List[Callable[[Animation], Animation]]
|
|
|
|
def handle_init(self, session_manager: SessionManager):
|
|
# Add our manglers into the list
|
|
LocalAnimAddon.anim_manglers.extend(self.ANIM_MANGLERS)
|
|
LocalAnimAddon.remangle_local_anims(session_manager)
|
|
|
|
def handle_unload(self, session_manager: SessionManager):
|
|
# Clean up our manglers before we go away
|
|
mangler_list = LocalAnimAddon.anim_manglers
|
|
for mangler in self.ANIM_MANGLERS:
|
|
if mangler in mangler_list:
|
|
mangler_list.remove(mangler)
|
|
LocalAnimAddon.remangle_local_anims(session_manager)
|
|
|
|
|
|
class BaseAnimHelperAddon(BaseAddon):
|
|
"""
|
|
Base class for local creation of procedural animations
|
|
|
|
Animation generated by build_anim() gets applied to all active sessions
|
|
"""
|
|
ANIM_NAME: str
|
|
|
|
def handle_session_init(self, session: Session):
|
|
self._reapply_anim(session, session.main_region)
|
|
|
|
def handle_session_closed(self, session: Session):
|
|
LocalAnimAddon.apply_local_anim(session, session.main_region, self.ANIM_NAME, None)
|
|
|
|
def handle_unload(self, session_manager: SessionManager):
|
|
for session in session_manager.sessions:
|
|
# TODO: Nasty. Since we need to access session-local attrs we need to set the
|
|
# context even though we also explicitly pass session and region.
|
|
# Need to rethink the LocalAnimAddon API.
|
|
with addon_ctx.push(session, session.main_region):
|
|
LocalAnimAddon.apply_local_anim(session, session.main_region, self.ANIM_NAME, None)
|
|
|
|
@abstractmethod
|
|
def build_anim(self) -> Animation:
|
|
pass
|
|
|
|
def _reapply_anim(self, session: Session, region: ProxiedRegion):
|
|
LocalAnimAddon.apply_local_anim(session, region, self.ANIM_NAME, self.build_anim().to_bytes())
|
|
|
|
|
|
addons = [LocalAnimAddon()]
|