├── .gitmodules ├── README.md ├── LICENSE.md ├── __init__.py ├── operators.py ├── pylivelinkface.py ├── bpylivelinkface.py └── timecode.py /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "bpy_utils"] 2 | path = bpy_utils 3 | url = git@github.com:nmfisher/bpy_utils.git 4 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Blender LiveLinkFace plugin 2 | 3 | Stream/import ARKit blendshapes via the iPhone LiveLinkFace app direct to Blender. 4 | 5 | [Support development by purchasing via Gumroad](https://nickfisher.gumroad.com/l/tvzndw) 6 | 7 | ## Notes 8 | 9 | HeadPitch/HeadYaw are inverted when using LLF in landscape orientation 10 | 11 | ## CSV Import 12 | 13 | [![Alt text](https://img.youtube.com/vi/Jexx_N8mRsI/0.jpg)](https://youtu.be/Jexx_N8mRsI) 14 | 15 | 16 | ## Credits 17 | 18 | - https://ciesie.com/post/blender_sockets/ 19 | - https://github.com/JimWest/PyLiveLinkFace 20 | - https://github.com/eoyilmaz/timecode 21 | - https://dragonboots.gumroad.com/l/metahumanhead 22 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Nick Fisher 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /__init__.py: -------------------------------------------------------------------------------- 1 | bl_info = { 2 | "author":"Nick Fisher ", 3 | "name":"LiveLinkFace Add-On", 4 | "blender":(3,1,0), 5 | "category":"3D View", 6 | "location": "View3D > Sidebar > LiveLinkFace" 7 | } 8 | 9 | import os, sys, bpy 10 | sys.path.append(os.getcwd()) 11 | from bpy_utils import register_custom_list_operators, unregister_custom_list_operators 12 | from livelinkface.operators import LiveLinkFacePanel, ConnectOperator, LoadCSVOperator 13 | 14 | def register(): 15 | register_custom_list_operators("ll", "ll_targets", "ll_index") 16 | bpy.utils.register_class(LiveLinkFacePanel) 17 | bpy.utils.register_class(ConnectOperator) 18 | bpy.utils.register_class(LoadCSVOperator) 19 | 20 | bpy.types.Scene.ll_is_listening = bpy.props.BoolProperty(name="Server listening", description="Whether the server is currently listening", default=False) 21 | bpy.types.Scene.ll_host_ip = bpy.props.StringProperty(name="Host IP", description="IP address of the interface on this machine to listen", default="0.0.0.0") 22 | bpy.types.Scene.ll_host_port = bpy.props.IntProperty(name="Port", description="Port", default=11111) 23 | 24 | bpy.types.Scene.ll_record_stream = bpy.props.BoolProperty( 25 | name="Record", 26 | description="When true, blendshapes will be saved as successive frames in the action", 27 | default = False 28 | ) 29 | 30 | bpy.types.Scene.invert_lr_mouth = bpy.props.BoolProperty( 31 | name="Invert Mouth L/R", 32 | description="Invert MouthLeft-MouthRight blendshapes", 33 | default = False) 34 | 35 | def unregister(): 36 | bpy.utils.unregister_class(LiveLinkFacePanel) 37 | bpy.utils.unregister_class(ConnectOperator) 38 | bpy.utils.unregister_class(LoadCSVOperator) 39 | unregister_custom_list_operators("ll","ll_targets", "ll_index") 40 | del bpy.types.Scene.ll_is_listening 41 | del bpy.types.Scene.ll_host_ip 42 | del bpy.types.Scene.ll_host_port 43 | del bpy.types.Scene.ll_record_stream 44 | del bpy.types.Scene.invert_lr_mouth 45 | 46 | if __name__ == "main": 47 | register() 48 | -------------------------------------------------------------------------------- /operators.py: -------------------------------------------------------------------------------- 1 | import bpy 2 | from bpy_extras.io_utils import ImportHelper 3 | 4 | from bpy.props import (IntProperty, 5 | BoolProperty, 6 | StringProperty, 7 | CollectionProperty, 8 | PointerProperty) 9 | 10 | from bpy_utils.operators import (CUSTOM_OT_actions) 11 | from bpy.types import (Operator, 12 | Panel, 13 | PropertyGroup, 14 | UIList) 15 | 16 | import livelinkface.bpylivelinkface as llf 17 | 18 | def checkPrereqs(context): 19 | if len(context.scene.ll_targets) == 0: 20 | self.report({"ERROR"}, "No target object selected") 21 | elif context.scene.ll_host_ip is None or len(context.scene.ll_host_ip) == 0: 22 | self.report({"ERROR"}, "No IP address set") 23 | elif context.scene.ll_host_port is None: 24 | self.report({"ERROR"}, "No port set") 25 | else: 26 | return True 27 | return False 28 | 29 | class LoadCSVOperator(Operator, ImportHelper): 30 | bl_idname = "scene.load_csv_operator" 31 | bl_label = "Load from CSV" 32 | 33 | filename_ext = ".csv" 34 | filter_glob: bpy.props.StringProperty(options={'HIDDEN'}, default='*.csv',maxlen=255) 35 | 36 | def execute(self, context): 37 | if checkPrereqs(context): 38 | #try: 39 | llf.LiveLinkTarget.from_csv([t.obj for t in context.scene.ll_targets], self.filepath) 40 | self.report({"INFO"}, "Loaded") 41 | return {'FINISHED'} 42 | #except Exception as e: 43 | # print(e) 44 | # self.report({"ERROR"}, f"Error loading from CSV : {self.filepath}") 45 | return {'CANCELLED'} 46 | 47 | class ConnectOperator(bpy.types.Operator): 48 | bl_idname = "scene.connect_operator" 49 | bl_label = "connectbutton" 50 | 51 | def execute(self, context): 52 | if llf.instance is not None and llf.instance.isListening(): 53 | try: 54 | llf.instance.close() 55 | llf.instance = None 56 | except: 57 | pass 58 | context.scene.ll_is_listening = False 59 | self.report({"INFO"}, "Disconnected") 60 | return {'FINISHED'} 61 | else: 62 | if checkPrereqs(context): 63 | try: 64 | llf.create_instance([t.obj for t in context.scene.ll_targets], context.scene.ll_record_stream, context.scene.ll_host_ip, context.scene.ll_host_port) 65 | llf.instance.listen() 66 | self.report({"INFO"}, "Started") 67 | except Exception as e: 68 | llf.instance.stopListening() 69 | self.report({"ERROR"}, f"Error connecting : {e}") 70 | return {'FINISHED'} 71 | return {"CANCELLED"} 72 | 73 | class LiveLinkFacePanel(bpy.types.Panel): 74 | bl_idname = "VIEW3D_PT_live_link_face" 75 | bl_label = "LiveLinkFace" 76 | bl_category = "LiveLinkFace" 77 | bl_space_type = "VIEW_3D" 78 | bl_region_type = "UI" 79 | bl_options = {"HEADER_LAYOUT_EXPAND"} 80 | 81 | invert_lr_mouth : BoolProperty( 82 | name="Invert Mouth L/R", 83 | description="Invert MouthLeft-MouthRight blendshapes", 84 | default = False 85 | ) 86 | 87 | def draw(self, context): 88 | box = self.layout.box() 89 | box.label(text="Target") 90 | 91 | rows = 2 92 | row = box.row() 93 | row.template_list("CUSTOM_UL_items", "", bpy.context.scene, "ll_targets", bpy.context.scene, "ll_index", rows=rows) 94 | col = row.column(align=True) 95 | col.operator("ll_custom.list_action", icon='ADD', text="").action = 'ADD' 96 | col.operator("ll_custom.list_action", icon='REMOVE', text="").action = 'REMOVE' 97 | 98 | box = self.layout.box() 99 | box.label(text="Stream") 100 | row = box.row() 101 | row.prop(context.scene, "ll_host_ip") 102 | row.prop(context.scene, "ll_host_port") 103 | row = box.row() 104 | 105 | row.prop(context.scene, "ll_record_stream", text="Record?") 106 | row.operator("scene.connect_operator", text="Disconnect" if llf.instance is not None and llf.instance.isListening() else "Connect") 107 | 108 | box = self.layout.box() 109 | box.label(text="Import") 110 | load_csv = box.operator("scene.load_csv_operator") 111 | 112 | box = self.layout.box() 113 | box.label(text="Adjustments") 114 | row = box.row() 115 | box.prop(context.scene, "invert_lr_mouth", text="Invert Mouth L/R") 116 | 117 | -------------------------------------------------------------------------------- /pylivelinkface.py: -------------------------------------------------------------------------------- 1 | from collections import deque 2 | from statistics import mean 3 | from enum import Enum 4 | import struct 5 | from typing import Tuple 6 | import datetime 7 | import uuid 8 | import numpy as np 9 | from livelinkface.timecode import Timecode 10 | 11 | class FaceBlendShape(Enum): 12 | EyeBlinkLeft = 0 13 | EyeLookDownLeft = 1 14 | EyeLookInLeft = 2 15 | EyeLookOutLeft = 3 16 | EyeLookUpLeft = 4 17 | EyeSquintLeft = 5 18 | EyeWideLeft = 6 19 | EyeBlinkRight = 7 20 | EyeLookDownRight = 8 21 | EyeLookInRight = 9 22 | EyeLookOutRight = 10 23 | EyeLookUpRight = 11 24 | EyeSquintRight = 12 25 | EyeWideRight = 13 26 | JawForward = 14 27 | JawLeft = 15 28 | JawRight = 16 29 | JawOpen = 17 30 | MouthClose = 18 31 | MouthFunnel = 19 32 | MouthPucker = 20 33 | MouthLeft = 21 34 | MouthRight = 22 35 | MouthSmileLeft = 23 36 | MouthSmileRight = 24 37 | MouthFrownLeft = 25 38 | MouthFrownRight = 26 39 | MouthDimpleLeft = 27 40 | MouthDimpleRight = 28 41 | MouthStretchLeft = 29 42 | MouthStretchRight = 30 43 | MouthRollLower = 31 44 | MouthRollUpper = 32 45 | MouthShrugLower = 33 46 | MouthShrugUpper = 34 47 | MouthPressLeft = 35 48 | MouthPressRight = 36 49 | MouthLowerDownLeft = 37 50 | MouthLowerDownRight = 38 51 | MouthUpperUpLeft = 39 52 | MouthUpperUpRight = 40 53 | BrowDownLeft = 41 54 | BrowDownRight = 42 55 | BrowInnerUp = 43 56 | BrowOuterUpLeft = 44 57 | BrowOuterUpRight = 45 58 | CheekPuff = 46 59 | CheekSquintLeft = 47 60 | CheekSquintRight = 48 61 | NoseSneerLeft = 49 62 | NoseSneerRight = 50 63 | TongueOut = 51 64 | HeadYaw = 52 65 | HeadPitch = 53 66 | HeadRoll = 54 67 | LeftEyeYaw = 55 68 | LeftEyePitch = 56 69 | LeftEyeRoll = 57 70 | RightEyeYaw = 58 71 | RightEyePitch = 59 72 | RightEyeRoll = 60 73 | 74 | 75 | class PyLiveLinkFace: 76 | """PyLiveLinkFace class 77 | 78 | Can be used to receive PyLiveLinkFace from the PyLiveLinkFace IPhone app or 79 | other PyLiveLinkFace compatible programs like this library. 80 | """ 81 | 82 | def __init__(self, name: str = "Python_LiveLinkFace", 83 | uuid: str = str(uuid.uuid1()), fps=60, 84 | filter_size: int = 5) -> None: 85 | # properties 86 | self.uuid = uuid 87 | self.name = name 88 | self.fps = fps 89 | self._filter_size = filter_size 90 | 91 | self._version = 6 92 | now = datetime.datetime.now() 93 | timcode = Timecode( 94 | self._fps, f'{now.hour}:{now.minute}:{now.second}:{now.microsecond * 0.001}') 95 | self._frames = timcode.frames 96 | self._sub_frame = 1056060032 # I don't know how to calculate this 97 | self._denominator = int(self._fps / 60) # 1 most of the time 98 | self._blend_shapes = [0.000] * 61 99 | self._old_blend_shapes = [] # used for filtering 100 | for i in range(61): 101 | self._old_blend_shapes.append(deque([0.0], maxlen = self._filter_size)) 102 | 103 | @property 104 | def uuid(self) -> str: 105 | return self._uuid 106 | 107 | @uuid.setter 108 | def uuid(self, value: str) -> None: 109 | # uuid needs to start with a $, if it doesn't add it 110 | if not value.startswith("$"): 111 | self._uuid = '$' + value 112 | else: 113 | self._uuid = value 114 | 115 | @property 116 | def name(self) -> str: 117 | return self._name 118 | 119 | @name.setter 120 | def name(self, value: str) -> None: 121 | self._name = value 122 | 123 | @property 124 | def fps(self) -> int: 125 | return self._fps 126 | 127 | @fps.setter 128 | def fps(self, value: int) -> None: 129 | if value < 1: 130 | raise ValueError("Only fps values greater than 1 are allowed.") 131 | self._fps = value 132 | 133 | def encode(self) -> bytes: 134 | """ Encodes the PyLiveLinkFace object into a bytes object so it can be 135 | send over a network. """ 136 | 137 | version_packed = struct.pack(' float: 153 | """ Get the current value of the blend shape. 154 | 155 | Parameters 156 | ---------- 157 | index : FaceBlendShape 158 | Index of the BlendShape to get the value from. 159 | 160 | Returns 161 | ------- 162 | float 163 | The value of the BlendShape. 164 | """ 165 | return self._blend_shapes[index.value] 166 | 167 | def set_blendshape(self, index: FaceBlendShape, value: float, 168 | no_filter: bool = False) -> None: 169 | """ Sets the value of the blendshape. 170 | 171 | The function will use mean to filter between the old and the new 172 | values, unless `no_filter` is set to True. 173 | 174 | Parameters 175 | ---------- 176 | index : FaceBlendShape 177 | Index of the BlendShape to get the value from. 178 | value: float 179 | Value to set the BlendShape to, should be in the range of 0 - 1 for 180 | the blendshapes and between -1 and 1 for the head rotation 181 | (yaw, pitch, roll). 182 | no_filter: bool 183 | If set to True, the blendshape will be set to the value without 184 | filtering. 185 | 186 | Returns 187 | ---------- 188 | None 189 | """ 190 | 191 | if no_filter: 192 | self._blend_shapes[index.value] = value 193 | else: 194 | self._old_blend_shapes[index.value].append(value) 195 | filterd_value = mean(self._old_blend_shapes[index.value]) 196 | self._blend_shapes[index.value] = filterd_value 197 | 198 | @staticmethod 199 | def decode(bytes_data: bytes): 200 | """ Decodes the given bytes (send from an PyLiveLinkFace App or from 201 | this library) and creates a new PyLiveLinkFace object. 202 | Returns True and the generated object if a face was found in the data, 203 | False an a new empty PyLiveLinkFace otherwise. 204 | 205 | Parameters 206 | ---------- 207 | bytes_data : bytes 208 | Bytes input to create the PyLiveLinkFace object from. 209 | 210 | Returns 211 | ------- 212 | bool 213 | True if the bytes data contained a face, False if not. 214 | PyLiveLinkFace 215 | The PyLiveLinkFace object. 216 | 217 | """ 218 | version = struct.unpack('b', bytes_data[0:1])[0] 219 | device_id_len = struct.unpack('!i', bytes_data[1:5])[0] 220 | device_id = bytes_data[5:5+device_id_len].decode("utf-8") 221 | name_length = struct.unpack('!i', bytes_data[5+device_id_len:5+device_id_len+4])[0] 222 | name_start_pos = 5 + device_id_len + 4 223 | name_end_pos = name_start_pos + name_length 224 | name = bytes_data[name_start_pos:name_end_pos].decode("utf-8") 225 | 226 | frame_number, sub_frame = struct.unpack( 227 | "!2i", bytes_data[name_end_pos:name_end_pos + 8]) 228 | fps, denominator = struct.unpack("!2i", bytes_data[name_end_pos+8:name_end_pos+16]) 229 | 230 | if len(bytes_data) > name_end_pos + 16: 231 | 232 | bs_count = struct.unpack("b", bytes_data[name_end_pos+16:name_end_pos+17])[0] 233 | 234 | #FFrameTime, FFrameRate and data length 235 | # BLEND_SHAPE_PACKET_VER (uint8), DeviceId (FName - ?), SubjectName (FName - ?), (FFrameRate (int32 + int32) + FFrameTime (int32)), uint8 BlendShapeCount, 236 | if bs_count != 61: 237 | raise ValueError( 238 | f'Blend shape length is {bs_count} but should be 61, something is wrong with the data.') 239 | 240 | data = struct.unpack( 241 | "!61f", bytes_data[name_end_pos + 17:]) 242 | live_link_face = PyLiveLinkFace(name, device_id, fps) 243 | live_link_face._version = version 244 | live_link_face._frames = frame_number 245 | live_link_face._sub_frame = sub_frame 246 | live_link_face._denominator = denominator 247 | live_link_face._blend_shapes = data 248 | 249 | return True, live_link_face 250 | return False, None 251 | 252 | -------------------------------------------------------------------------------- /bpylivelinkface.py: -------------------------------------------------------------------------------- 1 | import traceback 2 | import time 3 | import socket 4 | import bpy 5 | import csv 6 | import random 7 | 8 | from livelinkface.pylivelinkface import PyLiveLinkFace, FaceBlendShape 9 | 10 | LIVE_LINK_FACE_HEADER = "Timecode,BlendShapeCount,EyeBlinkLeft,EyeLookDownLeft,EyeLookInLeft,EyeLookOutLeft,EyeLookUpLeft,EyeSquintLeft,EyeWideLeft,EyeBlinkRight,EyeLookDownRight,EyeLookInRight,EyeLookOutRight,EyeLookUpRight,EyeSquintRight,EyeWideRight,JawForward,JawRight,JawLeft,JawOpen,MouthClose,MouthFunnel,MouthPucker,MouthRight,MouthLeft,MouthSmileLeft,MouthSmileRight,MouthFrownLeft,MouthFrownRight,MouthDimpleLeft,MouthDimpleRight,MouthStretchLeft,MouthStretchRight,MouthRollLower,MouthRollUpper,MouthShrugLower,MouthShrugUpper,MouthPressLeft,MouthPressRight,MouthLowerDownLeft,MouthLowerDownRight,MouthUpperUpLeft,MouthUpperUpRight,BrowDownLeft,BrowDownRight,BrowInnerUp,BrowOuterUpLeft,BrowOuterUpRight,CheekPuff,CheekSquintLeft,CheekSquintRight,NoseSneerLeft,NoseSneerRight,TongueOut,HeadYaw,HeadPitch,HeadRoll,LeftEyeYaw,LeftEyePitch,LeftEyeRoll,RightEyeYaw,RightEyePitch,RightEyeRoll".split(",") 11 | 12 | instance = None 13 | 14 | ''' 15 | Create a listener (an instance of LiveLinkFaceServer) on the given IP/port. 16 | Prefer using this method than constructing an instance directly as this will ensure that any pre-existing connections are closed 17 | ''' 18 | def create_instance(targets, record=False, host= "0.0.0.0", port = 11111): 19 | global instance 20 | if instance is not None: 21 | instance.close() 22 | instance = LiveLinkFaceServer(targets, record, host, port) 23 | 24 | ''' 25 | Interface for looking up shape key/custom properties by name and setting their respective weights on frames. 26 | ''' 27 | class LiveLinkTarget: 28 | 29 | ''' 30 | Construct an instance to manipulate frames on a single target object (which is an object within the Blender context). 31 | If the number of frames is known ahead of time (i.e. you are not working with streaming), this can be passed here. 32 | If you are streaming, pass num_frames=0 (or simply don't pass anything for the parameter and leave empty). 33 | The target should have at least one shape key or custom property with a name that corresponds to one of the entries in LIVE_LINK_FACE_HEADER. 34 | An exception will be raised if neither of these are present. 35 | ''' 36 | def __init__(self, target, num_frames=360, action_name=None): 37 | 38 | self.target = target 39 | self.frame_nums = list(range(num_frames)) 40 | 41 | # create an array-of-arrays to hold the (flattened) tuples of (frame_number,weight) for each shape key 42 | # i.e. each inner array will look like: 43 | # [ 0, v1, 1, v2, ..., N, vN ] 44 | # where v1 and v2 refer to the shape key weights at frames 0 and 1 respectively, and there are N frames in total 45 | # (note this will also create keyframes for non-LiveLinkFace shape keys on the mesh) 46 | # I can't find a better way to check if an object has shapekeys, so just use try-except 47 | try: 48 | self.sk_frame_data = [ [ i for co in zip(self.frame_nums, [0.0] * num_frames) for i in co ] for _ in range(len(self.target.data.shape_keys.key_blocks)) ] 49 | except: 50 | self.sk_frames = None 51 | # some ARKit blendshapes may drive bone rotations, rather than mesh-deforming shape keys 52 | # if a custom property exists on the target object whose name matches the incoming ARkit shape, the property will be animated 53 | # it is then your responsibility to create a driver in Blender to rotate the bone between its extremities (blendshape values -1 to 1 ) 54 | 55 | self.custom_props = [] 56 | for i in range(len(LIVE_LINK_FACE_HEADER) - 2): 57 | custom_prop = self.livelink_to_custom_prop(i) 58 | if custom_prop is not None: 59 | self.custom_props += [custom_prop] 60 | print(f"Found custom property {custom_prop} for ARkit blendshape : {LIVE_LINK_FACE_HEADER[i+2]}") 61 | 62 | # if the user hasn't already explicitly created a custom property on the target for head rotation 63 | # we automatically create it here 64 | for k in ["HeadPitch","HeadRoll","HeadYaw"]: 65 | if k not in self.custom_props: 66 | self.target[k] = 0.0 67 | print(f"Created custom property {k} on target object") 68 | self.custom_props += [ k ] 69 | 70 | print(f"Set custom_props to {self.custom_props}") 71 | self.custom_prop_framedata = [ [ i for co in zip(self.frame_nums, [0.0] * num_frames) for i in co ] for _ in range(len(self.custom_props)) ] 72 | 73 | if action_name is not None: 74 | self.create_action(action_name, num_frames) 75 | 76 | self.update_keyframes() 77 | 78 | ''' 79 | Try and resolve an ARKit blendshape-id to a named shape key in the target object. 80 | ARKit blendshape IDs are the integer index within LIVE_LINK_FACE_HEADER (offset to exclude the first two columns. 81 | ''' 82 | def livelink_to_shapekey_idx(self, ll_idx): 83 | name = LIVE_LINK_FACE_HEADER[ll_idx+2] 84 | 85 | # Invert Mouth Left and Rigth shapes to compensate for LiveLinkFace bug 86 | if bpy.context.scene.invert_lr_mouth: 87 | if name == 'MouthLeft': 88 | name = 'MouthRight' 89 | elif name == 'MouthRight': 90 | name = 'MouthLeft' 91 | 92 | for n in [name, name[0].lower() + name[1:]]: 93 | idx = self.target.data.shape_keys.key_blocks.find(n) 94 | if idx != -1: 95 | return idx 96 | return idx 97 | 98 | ''' 99 | Try and resolve an ARKit blendshape-id to a custom property in the target object. 100 | ARKit blendshape IDs are the integer index within LIVE_LINK_FACE_HEADER (offset to exclude the first two columns. 101 | ''' 102 | def livelink_to_custom_prop(self, ll_idx): 103 | name = LIVE_LINK_FACE_HEADER[ll_idx+2] 104 | 105 | # Invert Mouth Left and Rigth shapes to compensate for LiveLinkFace bug 106 | if bpy.context.scene.invert_lr_mouth: 107 | if name == 'MouthLeft': 108 | name = 'MouthRight' 109 | elif name == 'MouthRight': 110 | name = 'MouthLeft' 111 | 112 | for n in [name, name[0].lower() + name[1:]]: 113 | try: 114 | self.target[n] 115 | return n 116 | except: 117 | pass 118 | return None 119 | 120 | '''Sets the value for the LiveLink blendshape at index [i_ll] to [val] for frame [frame] (note the underlying target may be a blendshape or a bone).''' 121 | def set_frame_value(self, i_ll, frame, val): 122 | i_sk = self.livelink_to_shapekey_idx(i_ll) 123 | frame_data_offset = (2*frame)+1 124 | if i_sk != -1: 125 | self.sk_frame_data[i_sk][frame_data_offset] = val 126 | else: 127 | custom_prop = self.livelink_to_custom_prop(i_ll) 128 | if custom_prop is not None: 129 | custom_prop_idx = self.custom_props.index(custom_prop) 130 | self.custom_prop_framedata[custom_prop_idx][frame_data_offset] = val 131 | else: 132 | # print(f"Failed to find custom property for ARkit blendshape id {i_ll}") 133 | pass 134 | 135 | '''Loads a CSV in LiveLinkFace format. First line is the header (Timecode,BlendshapeCount,etc,etc), every line thereafter is a single frame with comma-separated weights''' 136 | @staticmethod 137 | def from_csv(targets,path,action_name="LiveLinkAction",use_first_frame_as_zero=False): 138 | with open(path,"r") as csv_file: 139 | csvdata = list(csv.reader(csv_file)) 140 | 141 | num_frames = len(csvdata) - 1 142 | 143 | targets = [LiveLinkTarget(target, num_frames, action_name=action_name) for target in targets] 144 | for idx,blendshape in enumerate(LIVE_LINK_FACE_HEADER): 145 | if idx < 2: 146 | continue 147 | 148 | rest_weight = float(csvdata[1][idx]) 149 | 150 | for i in range(1, num_frames): 151 | val = float(csvdata[i][idx]) 152 | if use_first_frame_as_zero: 153 | val -= rest_weight 154 | for target in targets: 155 | ll_idx = idx - 2 156 | frame=i-1 157 | target.set_frame_value(ll_idx, i, val) 158 | 159 | for target in targets: 160 | target.update_keyframes() 161 | 162 | return targets 163 | 164 | 165 | def create_action(self, action_name, num_frames): 166 | 167 | # create a new Action so we can directly create fcurves and set the keyframe points 168 | try: 169 | self.sk_action = bpy.data.actions[f"{action_name}_shapekey"] 170 | except: 171 | self.sk_action = bpy.data.actions.new(f"{action_name}_shapekey") 172 | 173 | # create the bone AnimData if it doesn't exist 174 | # important - we create this on the target (e.g. bpy.context.object), not its data (bpy.context.object.data) 175 | if self.target.animation_data is None: 176 | self.target.animation_data_create() 177 | 178 | # create the shape key AnimData if it doesn't exist 179 | if self.target.data.shape_keys.animation_data is None: 180 | self.target.data.shape_keys.animation_data_create() 181 | 182 | self.target.data.shape_keys.animation_data.action = self.sk_action 183 | 184 | self.sk_fcurves = [] 185 | self.custom_prop_fcurves = [] 186 | 187 | for sk in self.target.data.shape_keys.key_blocks: 188 | datapath = f"{sk.path_from_id()}.value" 189 | 190 | fc = self.sk_action.fcurves.find(datapath) 191 | if fc is None: 192 | print(f"Creating fcurve for shape key {sk.path_from_id()}") 193 | fc = self.sk_action.fcurves.new(datapath) 194 | fc.extrapolation="CONSTANT" 195 | fc.keyframe_points.add(count=num_frames) 196 | else: 197 | print(f"Found fcurve for shape key {sk.path_from_id()}") 198 | self.sk_fcurves += [fc] 199 | 200 | for custom_prop in self.custom_props: 201 | datapath = f"[\"{custom_prop}\"]" 202 | for i in range(num_frames): 203 | self.target.keyframe_insert(datapath,frame=i) 204 | self.custom_prop_fcurves += [fc for fc in self.target.animation_data.action.fcurves if fc.data_path == datapath] 205 | 206 | # this method actually sets the keyframe values via bpy 207 | def update_keyframes(self): 208 | # a bit slow to use bpy.context.object.data.shape_keys.keyframe_insert(datapath,frame=frame) 209 | # (where datapath is something like 'key_blocks["MouthOpen"].value') 210 | # better to add a new fcurve for each shape key then set the points in one go 211 | 212 | for i_sk,fc in enumerate(self.sk_fcurves): 213 | fc.keyframe_points.foreach_set('co',self.sk_frame_data[i_sk]) 214 | fc.update() 215 | 216 | for i_b,fc, in enumerate(self.custom_prop_fcurves): 217 | fc.keyframe_points.foreach_set('co',self.custom_prop_framedata[i_b]) 218 | fc.update() 219 | 220 | def update_to_frame(self, frame=0): 221 | self.target.data.shape_keys.key_blocks.foreach_set("value", self.sk_frames[frame]) 222 | for i,custom_prop in enumerate(self.custom_props): 223 | self.target[custom_prop] = self.custom_prop_framedata[i][(2*frame)+1] 224 | self.target.data.shape_keys.user.update() 225 | 226 | class LiveLinkFaceServer: 227 | 228 | def __init__(self, targets, record, host, udp_port): 229 | self.record = record 230 | self.start_frame = -1 231 | self.listening = False 232 | self.host = host 233 | self.port = udp_port 234 | self.targets = [ LiveLinkTarget(x,num_frames=3600,action_name=f"LiveLinkFace") for x in targets ] 235 | 236 | bpy.app.timers.register(self.read_from_socket) 237 | self.create_socket() 238 | print(f"Ready to receive network stream on {self.host}:{self.port}") 239 | 240 | def isListening(self): 241 | return self.listening 242 | 243 | def listen(self): 244 | self.listening = True 245 | self.start_frame = -1 246 | 247 | def stopListening(self): 248 | self.listening = False; 249 | 250 | def create_socket(self): 251 | self.sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) 252 | #s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) 253 | self.sock.setblocking(False) 254 | self.sock.bind((self.host, self.port)) 255 | 256 | def read_from_socket(self): 257 | if not self.listening: 258 | return 259 | interval = 1 / 60 260 | frame = None 261 | try: 262 | # continue reading from the socket until the buffer is drained 263 | while True: 264 | data, addr = self.sock.recvfrom(312) 265 | success, live_link_face = PyLiveLinkFace.decode(data) 266 | if success: 267 | if self.start_frame == -1: 268 | self.start_frame = live_link_face._frames 269 | if self.record: 270 | frame = live_link_face._frames - self.start_frame 271 | else: 272 | frame = 0 273 | for t in self.targets: 274 | for i in range(len(FaceBlendShape)): 275 | val = live_link_face.get_blendshape(FaceBlendShape(i)) 276 | t.set_frame_value(i, frame, val) 277 | except socket.error as e: 278 | pass 279 | except Exception as e: 280 | print(traceback.format_exc()) 281 | print(e) 282 | if frame is not None: 283 | if self.record: 284 | bpy.context.scene.frame_current = frame 285 | else: 286 | bpy.context.scene.frame_current = 0 287 | for t in self.targets: 288 | t.update_keyframes() 289 | 290 | return interval 291 | 292 | def close(self): 293 | try: 294 | bpy.app.timers.unregister(self.handle_data) 295 | except: 296 | print("Failed to unregister timer") 297 | pass 298 | self.sock.close() 299 | self.start_frame = 0 300 | 301 | -------------------------------------------------------------------------------- /timecode.py: -------------------------------------------------------------------------------- 1 | #!-*- coding: utf-8 -*- 2 | # The MIT License (MIT) 3 | # 4 | # Copyright (c) 2014 Joshua Banton and PyTimeCode developers 5 | # 6 | # Permission is hereby granted, free of charge, to any person obtaining a copy 7 | # of this software and associated documentation files (the "Software"), to deal 8 | # in the Software without restriction, including without limitation the rights 9 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | # copies of the Software, and to permit persons to whom the Software is 11 | # furnished to do so, subject to the following conditions: 12 | # 13 | # The above copyright notice and this permission notice shall be included in 14 | # all copies or substantial portions of the Software. 15 | # 16 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 22 | # THE SOFTWARE. 23 | 24 | __version__ = '1.3.1' 25 | 26 | 27 | class Timecode(object): 28 | """The main timecode class. 29 | 30 | Does all the calculation over frames, so the main data it holds is 31 | frames, then when required it converts the frames to a timecode by 32 | using the frame rate setting. 33 | 34 | :param framerate: The frame rate of the Timecode instance. It 35 | should be one of ['23.976', '23.98', '24', '25', '29.97', '30', '50', 36 | '59.94', '60', 'NUMERATOR/DENOMINATOR', ms'] where "ms" equals to 37 | 1000 fps. 38 | Can not be skipped. 39 | Setting the framerate will automatically set the :attr:`.drop_frame` 40 | attribute to correct value. 41 | :param start_timecode: The start timecode. Use this to be able to 42 | set the timecode of this Timecode instance. It can be skipped and 43 | then the frames attribute will define the timecode, and if it is also 44 | skipped then the start_second attribute will define the start 45 | timecode, and if start_seconds is also skipped then the default value 46 | of '00:00:00:00' will be used. 47 | When using 'ms' frame rate, timecodes like '00:11:01.040' use '.040' 48 | as frame number. When used with other frame rates, '.040' represents 49 | a fraction of a second. So '00:00:00.040'@25fps is 1 frame. 50 | :type framerate: str or int or float or tuple 51 | :type start_timecode: str or None 52 | :param start_seconds: A float or integer value showing the seconds. 53 | :param int frames: Timecode objects can be initialized with an 54 | integer number showing the total frames. 55 | :param force_non_drop_frame: If True, uses Non-Dropframe calculation for 56 | 29.97 or 59.94 only. Has no meaning for any other framerate. 57 | """ 58 | 59 | def __init__(self, framerate, start_timecode=None, start_seconds=None, 60 | frames=None, force_non_drop_frame=False): 61 | 62 | self.force_non_drop_frame = force_non_drop_frame 63 | 64 | self.drop_frame = False 65 | 66 | self.ms_frame = False 67 | self.fraction_frame = False 68 | self._int_framerate = None 69 | self._framerate = None 70 | self.framerate = framerate 71 | 72 | self._frames = None 73 | 74 | # attribute override order 75 | # start_timecode > frames > start_seconds 76 | if start_timecode: 77 | self.frames = self.tc_to_frames(start_timecode) 78 | else: 79 | if frames is not None: 80 | self.frames = frames 81 | elif start_seconds is not None: 82 | if start_seconds == 0: 83 | raise ValueError("``start_seconds`` argument can not be 0") 84 | self.frames = self.float_to_tc(start_seconds) 85 | else: 86 | # use default value of 00:00:00:00 87 | self.frames = self.tc_to_frames('00:00:00:00') 88 | 89 | @property 90 | def frames(self): 91 | """getter for the _frames attribute 92 | """ 93 | return self._frames 94 | 95 | @frames.setter 96 | def frames(self, frames): 97 | """setter for the _frames attribute 98 | 99 | :param int frames: A positive int bigger than zero showing the number 100 | of frames that this TimeCode represents. 101 | :return: 102 | """ 103 | # validate the frames value 104 | if not isinstance(frames, int): 105 | raise TypeError( 106 | "%s.frames should be a positive integer bigger " 107 | "than zero, not a %s" % (self.__class__.__name__, frames.__class__.__name__) 108 | ) 109 | 110 | if frames <= 0: 111 | raise ValueError( 112 | "%s.frames should be a positive integer bigger " 113 | "than zero, not %s" % (self.__class__.__name__, frames) 114 | ) 115 | self._frames = frames 116 | 117 | @property 118 | def framerate(self): 119 | """getter for _framerate attribute 120 | """ 121 | return self._framerate 122 | 123 | @framerate.setter 124 | def framerate(self, framerate): # lint:ok 125 | """setter for the framerate attribute 126 | :param framerate: 127 | :return: 128 | """ 129 | # Convert rational frame rate to float 130 | numerator = None 131 | denominator = None 132 | 133 | try: 134 | if '/' in framerate: 135 | numerator, denominator = framerate.split('/') 136 | except TypeError: 137 | # not a string 138 | pass 139 | 140 | if isinstance(framerate, tuple): 141 | numerator, denominator = framerate 142 | 143 | try: 144 | from fractions import Fraction 145 | if isinstance(framerate, Fraction): 146 | numerator = framerate.numerator 147 | denominator = framerate.denominator 148 | except ImportError: 149 | pass 150 | 151 | if numerator and denominator: 152 | framerate = round(float(numerator) / float(denominator), 2) 153 | if framerate.is_integer(): 154 | framerate = int(framerate) 155 | 156 | # check if number is passed and if so convert it to a string 157 | if isinstance(framerate, (int, float)): 158 | framerate = str(framerate) 159 | 160 | # set the int_frame_rate 161 | if framerate == '29.97': 162 | self._int_framerate = 30 163 | if self.force_non_drop_frame is True: 164 | self.drop_frame = False 165 | else: 166 | self.drop_frame = True 167 | elif framerate == '59.94': 168 | self._int_framerate = 60 169 | if self.force_non_drop_frame is True: 170 | self.drop_frame = False 171 | else: 172 | self.drop_frame = True 173 | elif any(map(lambda x: framerate.startswith(x), ['23.976', '23.98'])): 174 | self._int_framerate = 24 175 | elif framerate in ['ms', '1000']: 176 | self._int_framerate = 1000 177 | self.ms_frame = True 178 | framerate = 1000 179 | elif framerate == 'frames': 180 | self._int_framerate = 1 181 | else: 182 | self._int_framerate = int(float(framerate)) 183 | 184 | self._framerate = framerate 185 | 186 | def set_fractional(self, state): 187 | """Set or unset timecode to be represented with fractional seconds 188 | :param bool state: 189 | """ 190 | self.fraction_frame = state 191 | 192 | def set_timecode(self, timecode): 193 | """Sets the frames by using the given timecode 194 | """ 195 | self.frames = self.tc_to_frames(timecode) 196 | 197 | def float_to_tc(self, seconds): 198 | """set the frames by using the given seconds 199 | """ 200 | return int(seconds * self._int_framerate) 201 | 202 | def tc_to_frames(self, timecode): 203 | """Converts the given timecode to frames 204 | """ 205 | # timecode could be a Timecode instance 206 | if isinstance(timecode, Timecode): 207 | return timecode.frames 208 | 209 | hours, minutes, seconds, frames = map(int, self.parse_timecode(timecode)) 210 | 211 | if isinstance(timecode, int): 212 | time_tokens = [hours, minutes, seconds, frames] 213 | timecode = ':'.join(str(t) for t in time_tokens) 214 | 215 | if self.drop_frame: 216 | timecode = ';'.join(timecode.rsplit(':', 1)) 217 | 218 | if self.framerate != 'frames': 219 | ffps = float(self.framerate) 220 | else: 221 | ffps = float(self._int_framerate) 222 | 223 | if self.drop_frame: 224 | # Number of drop frames is 6% of framerate rounded to nearest 225 | # integer 226 | drop_frames = int(round(ffps * .066666)) 227 | else: 228 | drop_frames = 0 229 | 230 | # We don't need the exact framerate anymore, we just need it rounded to 231 | # nearest integer 232 | ifps = self._int_framerate 233 | 234 | # Number of frames per hour (non-drop) 235 | hour_frames = ifps * 60 * 60 236 | 237 | # Number of frames per minute (non-drop) 238 | minute_frames = ifps * 60 239 | 240 | # Total number of minutes 241 | total_minutes = (60 * hours) + minutes 242 | 243 | # Handle case where frames are fractions of a second 244 | if len(timecode.split('.')) == 2 and not self.ms_frame: 245 | self.fraction_frame = True 246 | fraction = timecode.rsplit('.', 1)[1] 247 | 248 | frames = int(round(float('.' + fraction) * ffps)) 249 | 250 | frame_number = \ 251 | ((hour_frames * hours) + (minute_frames * minutes) + 252 | (ifps * seconds) + frames) - \ 253 | (drop_frames * (total_minutes - (total_minutes // 10))) 254 | 255 | return frame_number + 1 # frames 256 | 257 | def frames_to_tc(self, frames): 258 | """Converts frames back to timecode 259 | 260 | :returns str: the string representation of the current time code 261 | """ 262 | if self.drop_frame: 263 | # Number of frames to drop on the minute marks is the nearest 264 | # integer to 6% of the framerate 265 | ffps = float(self.framerate) 266 | drop_frames = int(round(ffps * .066666)) 267 | else: 268 | ffps = float(self._int_framerate) 269 | drop_frames = 0 270 | 271 | # Number of frames per ten minutes 272 | frames_per_10_minutes = int(round(ffps * 60 * 10)) 273 | 274 | # Number of frames in a day - timecode rolls over after 24 hours 275 | frames_per_24_hours = int(round(ffps * 60 * 60 * 24)) 276 | 277 | # Number of frames per minute is the round of the framerate * 60 minus 278 | # the number of dropped frames 279 | frames_per_minute = int(round(ffps) * 60) - drop_frames 280 | 281 | frame_number = frames - 1 282 | 283 | # If frame_number is greater than 24 hrs, next operation will rollover 284 | # clock 285 | frame_number %= frames_per_24_hours 286 | 287 | if self.drop_frame: 288 | d = frame_number // frames_per_10_minutes 289 | m = frame_number % frames_per_10_minutes 290 | if m > drop_frames: 291 | frame_number += (drop_frames * 9 * d) + drop_frames * ((m - drop_frames) // frames_per_minute) 292 | else: 293 | frame_number += drop_frames * 9 * d 294 | 295 | ifps = self._int_framerate 296 | 297 | frs = frame_number % ifps 298 | if self.fraction_frame: 299 | frs = round(frs / float(ifps), 3) 300 | 301 | secs = int((frame_number // ifps) % 60) 302 | mins = int(((frame_number // ifps) // 60) % 60) 303 | hrs = int((((frame_number // ifps) // 60) // 60)) 304 | 305 | return hrs, mins, secs, frs 306 | 307 | def tc_to_string(self, hrs, mins, secs, frs): 308 | if self.fraction_frame: 309 | return "{hh:02d}:{mm:02d}:{ss:06.3f}".format( 310 | hh=hrs, mm=mins, ss=secs + frs 311 | ) 312 | 313 | ff = "%02d" 314 | if self.ms_frame: 315 | ff = "%03d" 316 | 317 | return ("%02d:%02d:%02d%s" + ff) % ( 318 | hrs, mins, secs, self.frame_delimiter, frs 319 | ) 320 | 321 | @classmethod 322 | def parse_timecode(cls, timecode): 323 | """parses timecode string NDF '00:00:00:00' or DF '00:00:00;00' or 324 | milliseconds/fractionofseconds '00:00:00.000' 325 | """ 326 | if isinstance(timecode, int): 327 | hex_repr = hex(timecode) 328 | # fix short string 329 | hex_repr = '0x%s' % (hex_repr[2:].zfill(8)) 330 | hrs, mins, secs, frs = tuple(map(int, [hex_repr[i:i + 2] for i in range(2, 10, 2)])) 331 | 332 | else: 333 | bfr = timecode.replace(';', ':').replace('.', ':').split(':') 334 | hrs = int(bfr[0]) 335 | mins = int(bfr[1]) 336 | secs = int(bfr[2]) 337 | frs = int(bfr[3]) 338 | 339 | return hrs, mins, secs, frs 340 | 341 | @property 342 | def frame_delimiter(self): 343 | """Return correct symbol based on framerate.""" 344 | if self.drop_frame: 345 | return ';' 346 | 347 | elif self.ms_frame or self.fraction_frame: 348 | return '.' 349 | 350 | else: 351 | return ':' 352 | 353 | def __iter__(self): 354 | yield self 355 | 356 | def next(self): 357 | self.add_frames(1) 358 | return self 359 | 360 | def back(self): 361 | self.sub_frames(1) 362 | return self 363 | 364 | def add_frames(self, frames): 365 | """adds or subtracts frames number of frames 366 | """ 367 | self.frames += frames 368 | 369 | def sub_frames(self, frames): 370 | """adds or subtracts frames number of frames 371 | """ 372 | self.add_frames(-frames) 373 | 374 | def mult_frames(self, frames): 375 | """multiply frames 376 | """ 377 | self.frames *= frames 378 | 379 | def div_frames(self, frames): 380 | """adds or subtracts frames number of frames""" 381 | self.frames = int(self.frames / frames) 382 | 383 | def __eq__(self, other): 384 | """the overridden equality operator 385 | """ 386 | if isinstance(other, Timecode): 387 | return self.framerate == other.framerate and self.frames == other.frames 388 | elif isinstance(other, str): 389 | new_tc = Timecode(self.framerate, other) 390 | return self.__eq__(new_tc) 391 | elif isinstance(other, int): 392 | return self.frames == other 393 | 394 | def __ge__(self, other): 395 | """override greater or equal to operator""" 396 | if isinstance(other, Timecode): 397 | return self.framerate == other.framerate and self.frames >= other.frames 398 | elif isinstance(other, str): 399 | new_tc = Timecode(self.framerate, other) 400 | return self.frames >= new_tc.frames 401 | elif isinstance(other, int): 402 | return self.frames >= other 403 | 404 | def __gt__(self, other): 405 | """override greater operator""" 406 | if isinstance(other, Timecode): 407 | return self.framerate == other.framerate and self.frames > other.frames 408 | elif isinstance(other, str): 409 | new_tc = Timecode(self.framerate, other) 410 | return self.frames > new_tc.frames 411 | elif isinstance(other, int): 412 | return self.frames > other 413 | 414 | def __le__(self, other): 415 | """override less or equal to operator""" 416 | if isinstance(other, Timecode): 417 | return self.framerate == other.framerate and self.frames <= other.frames 418 | elif isinstance(other, str): 419 | new_tc = Timecode(self.framerate, other) 420 | return self.frames <= new_tc.frames 421 | elif isinstance(other, int): 422 | return self.frames <= other 423 | 424 | def __lt__(self, other): 425 | """override less operator""" 426 | if isinstance(other, Timecode): 427 | return self.framerate == other.framerate and self.frames < other.frames 428 | elif isinstance(other, str): 429 | new_tc = Timecode(self.framerate, other) 430 | return self.frames < new_tc.frames 431 | elif isinstance(other, int): 432 | return self.frames < other 433 | 434 | def __add__(self, other): 435 | """returns new Timecode instance with the given timecode or frames 436 | added to this one 437 | """ 438 | # duplicate current one 439 | tc = Timecode(self.framerate, frames=self.frames) 440 | 441 | if isinstance(other, Timecode): 442 | tc.add_frames(other.frames) 443 | elif isinstance(other, int): 444 | tc.add_frames(other) 445 | else: 446 | raise TimecodeError( 447 | 'Type %s not supported for arithmetic.' % 448 | other.__class__.__name__ 449 | ) 450 | 451 | return tc 452 | 453 | def __sub__(self, other): 454 | """returns new Timecode instance with subtracted value""" 455 | if isinstance(other, Timecode): 456 | subtracted_frames = self.frames - other.frames 457 | elif isinstance(other, int): 458 | subtracted_frames = self.frames - other 459 | else: 460 | raise TimecodeError( 461 | 'Type %s not supported for arithmetic.' % 462 | other.__class__.__name__ 463 | ) 464 | 465 | return Timecode(self.framerate, frames=abs(subtracted_frames)) 466 | 467 | def __mul__(self, other): 468 | """returns new Timecode instance with multiplied value""" 469 | if isinstance(other, Timecode): 470 | multiplied_frames = self.frames * other.frames 471 | elif isinstance(other, int): 472 | multiplied_frames = self.frames * other 473 | else: 474 | raise TimecodeError( 475 | 'Type %s not supported for arithmetic.' % 476 | other.__class__.__name__ 477 | ) 478 | 479 | return Timecode(self.framerate, frames=multiplied_frames) 480 | 481 | def __div__(self, other): 482 | """returns new Timecode instance with divided value""" 483 | if isinstance(other, Timecode): 484 | div_frames = int(float(self.frames) / float(other.frames)) 485 | elif isinstance(other, int): 486 | div_frames = int(float(self.frames) / float(other)) 487 | else: 488 | raise TimecodeError( 489 | 'Type %s not supported for arithmetic.' % 490 | other.__class__.__name__ 491 | ) 492 | 493 | return Timecode(self.framerate, frames=div_frames) 494 | 495 | def __truediv__(self, other): 496 | """returns new Timecode instance with divided value""" 497 | return self.__div__(other) 498 | 499 | def __repr__(self): 500 | return self.tc_to_string(*self.frames_to_tc(self.frames)) 501 | 502 | @property 503 | def hrs(self): 504 | hrs, mins, secs, frs = self.frames_to_tc(self.frames) 505 | return hrs 506 | 507 | @property 508 | def mins(self): 509 | hrs, mins, secs, frs = self.frames_to_tc(self.frames) 510 | return mins 511 | 512 | @property 513 | def secs(self): 514 | hrs, mins, secs, frs = self.frames_to_tc(self.frames) 515 | return secs 516 | 517 | @property 518 | def frs(self): 519 | hrs, mins, secs, frs = self.frames_to_tc(self.frames) 520 | return frs 521 | 522 | @property 523 | def frame_number(self): 524 | """returns the 0 based frame number of the current timecode instance 525 | """ 526 | return self.frames - 1 527 | 528 | @property 529 | def float(self): 530 | """returns the seconds as float 531 | """ 532 | return float(self.frames) / float(self._int_framerate) 533 | 534 | 535 | class TimecodeError(Exception): 536 | """Raised when an error occurred in timecode calculation 537 | """ 538 | pass 539 | --------------------------------------------------------------------------------