Skip to content

Commit

Permalink
Add timecode support
Browse files Browse the repository at this point in the history
  • Loading branch information
jamesmosys committed Jun 19, 2024
1 parent cfbff59 commit 0f624fc
Show file tree
Hide file tree
Showing 6 changed files with 239 additions and 40 deletions.
113 changes: 109 additions & 4 deletions src/main/python/camdkit/framework.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,43 @@ class Transform:
name: typing.Optional[str] = None
parent: typing.Optional[str] = None

class TimingMode(Enum):
INTERNAL = "internal"
EXTERNAL = "external"

def __str__(self):
return self.value

class TimecodeFormat(Enum):
TC_24 = "24"
TC_24D = "24D"
TC_25 = "25"
TC_30 = "30"
TC_30D = "30D"

@classmethod
def to_int(cls, value):
if value == cls.TC_24 or value == cls.TC_24D: return 24
if value == cls.TC_25: return 25
if value == cls.TC_30 or value == cls.TC_30D: return 30
raise ValueError

def __str__(self):
return self.value

@staticmethod
def from_string(value):
return TimecodeFormat(value)

@dataclasses.dataclass
class Timecode:
"Timecode is a standard for labeling individual frames of data in media systems."
hour: int
minute: int
second: int
frame: int
format: TimecodeFormat

class Parameter:
"""Metadata parameter base class"""

Expand Down Expand Up @@ -261,7 +298,7 @@ class StrictlyPositiveIntegerParameter(Parameter):
def validate(value) -> bool:
"""The parameter shall be a integer in the range (0..2,147,483,647]."""

return isinstance(value, numbers.Integral) and value > 0
return isinstance(value, numbers.Integral) and value >= 0

@staticmethod
def to_json(value: typing.Any) -> typing.Any:
Expand All @@ -275,7 +312,7 @@ def from_json(value: typing.Any) -> typing.Any:
def make_json_schema() -> dict:
return {
"type": "integer",
"minimum": 1,
"minimum": 0,
"maximum": 2147483647
}

Expand All @@ -284,7 +321,7 @@ class EnumParameter(StringParameter):

def validate(self, value) -> bool:
"""The parameter shall be one of the allowed values."""
return isinstance(value, str) and value in self.allowedValues
return str(value) in self.allowedValues

def make_json_schema(self) -> dict:
return {
Expand All @@ -294,7 +331,75 @@ def make_json_schema(self) -> dict:

class TimingModeParameter(EnumParameter):
sampling = Sampling.REGULAR
allowedValues = ["internal", "external"]
allowedValues = [e.value for e in TimingMode]

class TimecodeParameter(Parameter):
sampling = Sampling.REGULAR

@staticmethod
def validate(value) -> bool:
"""
The parameter shall contain a tuple of Timecodes that each have a valid format and
hours, minutes, seconds and frames with appropriate min/max values.
"""

if not isinstance(value, Timecode):
return False
if not isinstance(value.format, TimecodeFormat):
return False
if not (isinstance(value.hour, int) and value.hour >= 0 and value.hour < 24):
return False
if not (isinstance(value.minute, int) and value.minute >= 0 and value.minute < 60):
return False
if not (isinstance(value.second, int) and value.second >= 0 and value.second < 60):
return False
if not (isinstance(value.frame, int) and value.frame >= 0 and value.frame < TimecodeFormat.to_int(value.format)):
return False
return True

@staticmethod
def to_json(value: typing.Any) -> typing.Any:
d = dataclasses.asdict(value)
d["format"] = str(d["format"])
return d

@staticmethod
def from_json(value: typing.Any) -> typing.Any:
return Timecode(value["hour"], value["minute"], value["second"], value["frame"],
TimecodeFormat.from_string(value["format"]))

@staticmethod
def make_json_schema() -> dict:
return {
"type": "object",
"additionalProperties": False,
"properties": {
"hour": {
"type": "integer",
"minimum": 0,
"maximum": 23
},
"minute": {
"type": "integer",
"minimum": 0,
"maximum": 59
},
"second": {
"type": "integer",
"minimum": 0,
"maximum": 59
},
"frame": {
"type": "integer",
"minimum": 0,
"maximum": 29
},
"format": {
"type": "string",
"enum": ["24", "24D", "25", "30", "30D"]
}
}
}

class TransformsParameter(Parameter):
sampling = Sampling.REGULAR
Expand Down
23 changes: 18 additions & 5 deletions src/main/python/camdkit/model.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,7 @@
import numbers
import typing

from camdkit.framework import ParameterContainer, StrictlyPositiveRationalParameter, \
StrictlyPositiveIntegerParameter, StringParameter, Sampling, \
IntegerDimensionsParameter, Dimensions, UUIDURNParameter, Parameter, \
RationalParameter, TransformsParameter, TimingModeParameter
from camdkit.framework import *

class ActiveSensorPhysicalDimensions(IntegerDimensionsParameter):
"Height and width of the active area of the camera sensor"
Expand Down Expand Up @@ -218,7 +215,21 @@ class TimingMode(TimingModeParameter):
"""
canonical_name = "mode"
section = "timing"
allowedValues = ["internal", "external"]

class TimingSequenceNumber(StrictlyPositiveIntegerParameter):
"""
TODO doc
"""
sampling = Sampling.REGULAR
canonical_name = "sequence_number"
section = "timing"

class TimingTimecode(TimecodeParameter):
"""
TODO doc
"""
canonical_name = "timecode"
section = "timing"

class Clip(ParameterContainer):
"""Metadata for a camera clip.
Expand Down Expand Up @@ -247,6 +258,8 @@ class Clip(ParameterContainer):
transforms: typing.Optional[typing.Tuple[TransformsParameter]] = Transforms()
# TODO this to test enumerations
timing_mode: typing.Optional[typing.Tuple[TimingModeParameter]] = TimingMode()
timing_sequence_number: typing.Optional[typing.Tuple[TimingSequenceNumber]] = TimingSequenceNumber()
timing_timecode: typing.Optional[typing.Tuple[TimingTimecode]] = TimingTimecode()

def append(self, clip):
"Helper to add another clip's parameters to this clip's REGULAR data tuples"
Expand Down
37 changes: 13 additions & 24 deletions src/main/python/camdkit/mosys/f4.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@

import struct

from camdkit.framework import Vector3, Rotator3, Transform
from camdkit.framework import Vector3, Rotator3, Transform, Timecode, TimecodeFormat
from camdkit.model import Clip

class F4:
Expand Down Expand Up @@ -38,13 +38,6 @@ class F4:
ANGLE_FACTOR = 1000
LINEAR_FACTOR = 1000

class Timecode:
hours = 0
minutes = 0
seconds = 0
frames = 0
frame_rate = 0.0

class F4AxisBlock:
axis_id: int = 0
axis_status: int = 0
Expand All @@ -53,23 +46,19 @@ class F4AxisBlock:
data_bits3: int = 0

def to_timecode(self) -> Timecode:
timecode = Timecode()
match ((self._axisStatus >> 5) & 0b11):
match ((self.axis_status >> 5) & 0b11):
case 0b00:
timecode.frame_rate = 24.0
format = TimecodeFormat.TC_24
case 0b01:
timecode.frame_rate = 25.0
format = TimecodeFormat.TC_25
case 0b10:
timecode.frame_rate = 30.0
case 0b11:
timecode.frame_rate = 60.0
format = TimecodeFormat.TC_30

if timecode.frame_rate > 0.0:
timecode.hours = (self.data_bits1 >> 2) % 24
timecode.minutes = ((self.data_bits1 << 4) % 64) + ((self.data_bits2 >> 4) % 16)
timecode.seconds = ((self.data_bits2 << 2) % 64) + ((self.data_bits3 >> 6) % 4)
timecode.frames = self.data_bits3 % 64
return timecode
hours = (self.data_bits1 >> 2) % 24
minutes = ((self.data_bits1 << 4) % 64) + ((self.data_bits2 >> 4) % 16)
seconds = ((self.data_bits2 << 2) % 64) + ((self.data_bits3 >> 6) % 4)
frames = self.data_bits3 % 64
return Timecode(hours,minutes,seconds,frames,format)

class F4Packet:
command_byte: int = 0
Expand Down Expand Up @@ -172,14 +161,14 @@ def initialise(self, buffer: bytes) -> bool:
return True

def get_tracking_frame(self) -> Clip:
# Creates a TrackingClip with a single frame of data of each parameter
# Populates a Clip with a single frame of data of each parameter
# TODO JU Complete once the model is defined
frame = Clip()
if self._initialised:
translation = Vector3()
rotation = Rotator3()
frame.timing_mode = ("internal",)
#frame.timing.packet_sequence_number = self.frame_number
frame.timing_sequence_number = (self._frame_number,)
#frame.metadta.recording = (self._packet.status & (1 << 4)) != 0
#frame.timing.synchronization.source = SynchronizationSource.genlock
#frame.timing.synchronization.enabled = true
Expand Down Expand Up @@ -238,7 +227,7 @@ def get_tracking_frame(self) -> Clip:
#frame.lens.encoders.zoom = self._axis_block_to_lens_type(axis_block) / 65536.0
pass
case F4.FIELD_ID_TIMECODE:
#frame.time.timecode = axis_block.to_timecode()
frame.timing_timecode = (axis_block.to_timecode(),)
pass
# In this case there is only one transform
transform = Transform(translation=translation, rotation=rotation)
Expand Down
60 changes: 58 additions & 2 deletions src/test/python/test_framework.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,13 +61,69 @@ class EnumParameterTest(unittest.TestCase):

def test_allowed_values(self):
param = framework.TimingModeParameter()
self.assertTrue(param.validate("internal"))
self.assertTrue(param.validate("external"))
self.assertTrue(param.validate(framework.TimingMode.INTERNAL))
self.assertTrue(param.validate(framework.TimingMode.EXTERNAL))
self.assertFalse(param.validate(""))
self.assertFalse(param.validate("a"))
self.assertFalse(param.validate(None))
self.assertFalse(param.validate(0))

class TimecodeTest(unittest.TestCase):

def test_timecode_format(self):
self.assertEqual(framework.TimecodeFormat.to_int(framework.TimecodeFormat.TC_24), 24)
self.assertEqual(framework.TimecodeFormat.to_int(framework.TimecodeFormat.TC_24D), 24)
self.assertEqual(framework.TimecodeFormat.to_int(framework.TimecodeFormat.TC_25), 25)
self.assertEqual(framework.TimecodeFormat.to_int(framework.TimecodeFormat.TC_30), 30)
self.assertEqual(framework.TimecodeFormat.to_int(framework.TimecodeFormat.TC_30D), 30)
with self.assertRaises(TypeError):
framework.TimecodeFormat.to_int()
with self.assertRaises(ValueError):
framework.TimecodeFormat.to_int(0)
framework.TimecodeFormat.to_int(24)

def test_timecode_formats(self):
with self.assertRaises(TypeError):
framework.TimecodeParameter.validate(framework.Timecode())
framework.TimecodeParameter.validate(framework.Timecode(1,2,3))
framework.TimecodeParameter.validate(framework.Timecode(0,0,0,0))
self.assertFalse(framework.TimecodeParameter.validate(framework.Timecode(0,0,0,0,0)))
self.assertTrue(framework.TimecodeParameter.validate(framework.Timecode(0,0,0,0,framework.TimecodeFormat.TC_24)))
self.assertTrue(framework.TimecodeParameter.validate(framework.Timecode(1,2,3,4,framework.TimecodeFormat.TC_24)))
self.assertTrue(framework.TimecodeParameter.validate(framework.Timecode(23,59,59,23,framework.TimecodeFormat.TC_24)))
self.assertFalse(framework.TimecodeParameter.validate(framework.Timecode(-1,2,3,4,framework.TimecodeFormat.TC_24)))
self.assertFalse(framework.TimecodeParameter.validate(framework.Timecode(24,2,3,4,framework.TimecodeFormat.TC_24)))
self.assertFalse(framework.TimecodeParameter.validate(framework.Timecode(1,-1,3,4,framework.TimecodeFormat.TC_24)))
self.assertFalse(framework.TimecodeParameter.validate(framework.Timecode(1,60,3,4,framework.TimecodeFormat.TC_24)))
self.assertFalse(framework.TimecodeParameter.validate(framework.Timecode(1,2,-1,4,framework.TimecodeFormat.TC_24)))
self.assertFalse(framework.TimecodeParameter.validate(framework.Timecode(1,2,60,4,framework.TimecodeFormat.TC_24)))
self.assertFalse(framework.TimecodeParameter.validate(framework.Timecode(1,2,3,-1,framework.TimecodeFormat.TC_24)))
self.assertFalse(framework.TimecodeParameter.validate(framework.Timecode(1,2,3,24,framework.TimecodeFormat.TC_24)))
self.assertFalse(framework.TimecodeParameter.validate(framework.Timecode(1,2,3,24,framework.TimecodeFormat.TC_24D)))
self.assertFalse(framework.TimecodeParameter.validate(framework.Timecode(1,2,3,25,framework.TimecodeFormat.TC_25)))
self.assertFalse(framework.TimecodeParameter.validate(framework.Timecode(1,2,3,30,framework.TimecodeFormat.TC_30)))
self.assertFalse(framework.TimecodeParameter.validate(framework.Timecode(1,2,3,30,framework.TimecodeFormat.TC_30D)))

def test_from_dict(self):
r = framework.TimecodeParameter.from_json({
"hour": 1,
"minute": 2,
"second": 3,
"frame": 4,
"format": framework.TimecodeFormat.TC_24
})
self.assertEqual(r, framework.Timecode(1,2,3,4,framework.TimecodeFormat.TC_24))

def test_to_dict(self):
j = framework.TimecodeParameter.to_json(framework.Timecode(1,2,3,4,framework.TimecodeFormat.TC_24))
self.assertDictEqual(j, {
"hour": 1,
"minute": 2,
"second": 3,
"frame": 4,
"format": str(framework.TimecodeFormat.TC_24)
})


class TransformsTest(unittest.TestCase):

Expand Down
40 changes: 37 additions & 3 deletions src/test/python/test_model.py
Original file line number Diff line number Diff line change
Expand Up @@ -262,9 +262,43 @@ def test_timing_model(self):
with self.assertRaises(ValueError):
clip.timing_mode = "a"

value = "external"
clip.timing_mode = (value,)
self.assertEqual(clip.timing_mode, (value,))
value = (camdkit.framework.TimingMode.INTERNAL, camdkit.framework.TimingMode.EXTERNAL)
clip.timing_mode = value
self.assertEqual(clip.timing_mode, value)

def test_timing_timecode_model(self):
clip = camdkit.model.Clip()

self.assertIsNone(clip.timing_timecode)

with self.assertRaises(ValueError):
clip.timing_timecode = {}
with self.assertRaises(ValueError):
clip.timing_timecode = {1,2,3,24,"24"}

value = camdkit.framework.Timecode(1,2,3,4,camdkit.framework.TimecodeFormat.TC_24)
clip.timing_timecode = (value,)
self.assertEqual(clip.timing_timecode, (value,))

def test_timing_sequence_number(self):
clip = camdkit.model.Clip()

self.assertIsNone(clip.timing_sequence_number)

with self.assertRaises(ValueError):
clip.timing_sequence_number = [Fraction(5,7)]

with self.assertRaises(ValueError):
clip.timing_sequence_number = -1

with self.assertRaises(ValueError):
clip.timing_sequence_number = (-1,)

value = (0, 1)

clip.timing_sequence_number = value

self.assertTupleEqual(clip.timing_sequence_number, value)

def test_transforms_model(self):
clip = camdkit.model.Clip()
Expand Down
Loading

0 comments on commit 0f624fc

Please sign in to comment.