"""
Timerange class for representing time intervals in DFTT Timecode library.
This module provides the DfttTimeRange class for working with time intervals,
supporting operations like offset, extend, intersection, union, and iteration.
"""
from fractions import Fraction
from typing import Optional, List, Iterator, Union
from dftt_timecode.core.dftt_timecode import DfttTimecode
from dftt_timecode.error import (
DFTTError,
DFTTTimeRangeFPSError,
DFTTTimeRangeMethodError,
DFTTTimeRangeTypeError,
DFTTTimeRangeValueError,
)
from dftt_timecode.logging_config import get_logger
# Set up logger with automatic level detection based on git branch
logger = get_logger(__name__)
[docs]
class DfttTimeRange:
"""High-precision timerange class for representing time intervals.
DfttTimeRange represents a time interval with a start point, duration, and direction.
It provides comprehensive operations for manipulating time ranges including offset,
extend, shorten, reverse, retime, intersection, and union operations.
The class uses :class:`fractions.Fraction` internally for precise calculations,
ensuring frame-accurate operations even with complex interval manipulations.
Args:
start_tc: Start timecode. Can be a DfttTimecode object or any value
that can construct a DfttTimecode. Required if not using precise parameters.
end_tc: End timecode. Can be a DfttTimecode object or any value
that can construct a DfttTimecode. Required if not using precise parameters.
forward: Direction of the timerange. True for forward (start < end),
False for backward (start > end). Defaults to True.
fps: Frame rate in frames per second. Used when constructing timecodes
from non-timecode values. Defaults to 24.0.
start_precise_time: Internal construction parameter - precise start time
as Fraction. Use with precise_duration for direct construction.
precise_duration: Internal construction parameter - precise duration
as Fraction. Use with start_precise_time for direct construction.
strict_24h: Enable 24-hour constraint mode. When True, the timerange
duration cannot exceed 24 hours and midnight-crossing ranges are
handled specially. Defaults to False.
Attributes:
fps (float): Frame rate of the timerange
forward (bool): Direction of the timerange
strict_24h (bool): Whether 24-hour constraint is enabled
precise_duration (Fraction): Duration as high-precision Fraction
start_precise_time (Fraction): Start time as high-precision Fraction
end_precise_time (Fraction): End time as high-precision Fraction
duration (float): Duration in seconds (absolute value)
framecount (int): Duration in frames (absolute value)
start (DfttTimecode): Start timecode object
end (DfttTimecode): End timecode object
Raises:
DFTTTimeRangeValueError: When creating zero-length or invalid timeranges
DFTTTimeRangeFPSError: When start and end timecodes have mismatched fps
ValueError: When neither (start_tc, end_tc) nor (start_precise_time, precise_duration) are provided
Examples:
Create from two timecodes::
>>> tr = DfttTimeRange('01:00:00:00', '02:00:00:00', fps=24)
>>> print(tr.duration)
3600.0
>>> print(tr.framecount)
86400
Create backward timerange::
>>> tr = DfttTimeRange('02:00:00:00', '01:00:00:00', forward=False, fps=24)
>>> print(tr.start)
02:00:00:00
>>> print(tr.end)
01:00:00:00
Operations::
>>> tr = DfttTimeRange('01:00:00:00', '01:10:00:00', fps=24)
>>> tr2 = tr.offset(600) # Move forward 600 seconds (10 minutes)
>>> print(tr2.start)
01:10:00:00
>>> tr3 = tr.extend(300) # Add 5 minutes to duration
>>> print(tr3.duration)
900.0
Iteration::
>>> tr = DfttTimeRange('01:00:00:00', '01:00:00:10', fps=24)
>>> for tc in tr:
... print(tc)
01:00:00:00
01:00:00:01
...
01:00:00:09
Set operations::
>>> tr1 = DfttTimeRange('01:00:00:00', '02:00:00:00', fps=24)
>>> tr2 = DfttTimeRange('01:30:00:00', '02:30:00:00', fps=24)
>>> intersection = tr1 & tr2 # Intersection operator
>>> print(intersection.start)
01:30:00:00
>>> union = tr1 | tr2 # Union operator
>>> print(union.duration)
5400.0
Note:
- Timerange objects are immutable. All operations return new instances.
- The internal representation uses :class:`fractions.Fraction` for precision.
- Forward and backward timeranges behave differently in some operations.
- Zero-length timeranges are not allowed.
See Also:
- :class:`DfttTimecode`: For working with individual timecodes
- :mod:`dftt_timecode.error`: Custom exception classes
"""
TIME_24H_SECONDS = 86400
"""Constant representing 24 hours in seconds (86400)."""
[docs]
def __init__(
self,
start_tc=None,
end_tc=None,
forward: bool = True,
fps=24.0,
start_precise_time: Optional[Fraction] = None,
precise_duration: Optional[Fraction] = None,
strict_24h: bool = False,
):
self.__fps = fps
self.__forward = forward
self.__strict_24h = strict_24h
# Initialize based on construction method
if start_precise_time is not None and precise_duration is not None:
# Direct construction with precise values
self.__start_precise_time = Fraction(start_precise_time)
self.__precise_duration = Fraction(precise_duration)
if self.__precise_duration == 0:
raise DFTTTimeRangeValueError("Time range cannot be zero-length!")
elif start_tc is not None and end_tc is not None:
# Construction from start and end timecodes
self._init_from_timecodes(start_tc, end_tc)
else:
raise DFTTTimeRangeValueError(
"Must provide either start_tc+end_tc or start_precise_time+precise_duration"
)
# Validate 24h constraint
if self.__strict_24h and abs(self.__precise_duration) > self.TIME_24H_SECONDS:
logger.error(
f"Duration {abs(self.__precise_duration)}s exceeds 24 hours ({self.TIME_24H_SECONDS}s) in strict mode"
)
raise DFTTTimeRangeValueError("Duration exceeds 24 hours in strict mode")
logger.debug(
f"TimeRange created: start={float(self.__start_precise_time):.3f}s, "
f"duration={float(self.__precise_duration):.3f}s, fps={self.__fps}, "
f"forward={self.__forward}, strict_24h={self.__strict_24h}"
)
def _init_from_timecodes(self, start_tc, end_tc):
"""Initialize from start and end timecodes"""
# Convert inputs to DfttTimecode objects
if isinstance(start_tc, DfttTimecode) and isinstance(end_tc, DfttTimecode):
if start_tc.fps != end_tc.fps:
logger.error(
f"FPS mismatch: start_tc fps={start_tc.fps}, end_tc fps={end_tc.fps}"
)
raise DFTTTimeRangeFPSError(
"FPS mismatch between start and end timecodes"
)
self.__fps = start_tc.fps
start_precise = start_tc.precise_timestamp
end_precise = end_tc.precise_timestamp
elif isinstance(start_tc, DfttTimecode):
self.__fps = start_tc.fps
start_precise = start_tc.precise_timestamp
end_tc = DfttTimecode(
end_tc,
fps=self.__fps,
drop_frame=start_tc.is_drop_frame,
strict=start_tc.is_strict,
)
end_precise = end_tc.precise_timestamp
elif isinstance(end_tc, DfttTimecode):
self.__fps = end_tc.fps
end_precise = end_tc.precise_timestamp
start_tc = DfttTimecode(
start_tc,
fps=self.__fps,
drop_frame=end_tc.is_drop_frame,
strict=end_tc.is_strict,
)
start_precise = start_tc.precise_timestamp
else:
start_tc = DfttTimecode(start_tc, fps=self.__fps)
end_tc = DfttTimecode(end_tc, fps=self.__fps)
start_precise = start_tc.precise_timestamp
end_precise = end_tc.precise_timestamp
# Calculate precise duration based on direction
if self.__forward:
self.__precise_duration = end_precise - start_precise
else:
self.__precise_duration = start_precise - end_precise
# Handle midnight crossing in strict mode
if self.__strict_24h and self.__precise_duration < 0:
self.__precise_duration += self.TIME_24H_SECONDS
if self.__precise_duration == 0:
logger.error("Cannot create zero-length timerange")
raise DFTTTimeRangeValueError("Time range cannot be zero-length!")
self.__start_precise_time = start_precise
@property
def fps(self) -> float:
"""Frame rate of the timerange"""
return self.__fps
@property
def forward(self) -> bool:
"""Direction of the timerange"""
return self.__forward
@property
def strict_24h(self) -> bool:
"""Whether timerange is constrained to 24 hours"""
return self.__strict_24h
@property
def precise_duration(self) -> Fraction:
"""Precise duration as Fraction to avoid calculation errors"""
return self.__precise_duration
@property
def start_precise_time(self) -> Fraction:
"""Start time as precise Fraction"""
return self.__start_precise_time
@property
def end_precise_time(self) -> Fraction:
"""End time as precise Fraction"""
if self.__forward:
return self.__start_precise_time + self.__precise_duration
else:
return self.__start_precise_time - self.__precise_duration
@property
def duration(self) -> float:
"""Duration in seconds"""
return float(abs(self.__precise_duration))
@property
def framecount(self) -> int:
"""Duration in frames"""
return int(round(float(abs(self.__precise_duration)) * self.__fps))
@property
def start(self) -> DfttTimecode:
"""Start timecode"""
return DfttTimecode(float(self.__start_precise_time), fps=self.__fps)
@property
def end(self) -> DfttTimecode:
"""End timecode"""
return DfttTimecode(float(self.end_precise_time), fps=self.__fps)
# Core timerange operations
[docs]
def offset(self, offset_value: Union[float, DfttTimecode, str, int]) -> "DfttTimeRange":
"""Move timerange in time while preserving duration.
Shifts the entire timerange by the specified offset amount without
changing the duration. Both start and end points move by the same amount.
Args:
offset_value: Amount to offset the timerange. Can be:
- float: Seconds to shift
- int: Frames to shift (converted using current fps)
- DfttTimecode: Uses the timecode's timestamp
- str: Timecode string to parse
Returns:
DfttTimeRange: New timerange with shifted start/end points
Raises:
DFTTTimeRangeMethodError: If offset_value cannot be parsed
Examples:
>>> tr = DfttTimeRange('01:00:00:00', '01:10:00:00', fps=24)
>>> tr2 = tr.offset(600) # Offset by 10 minutes (600 seconds)
>>> print(tr2.start)
01:10:00:00
>>> print(tr2.end)
01:20:00:00
Note:
In strict_24h mode, the new start time wraps around at 24 hours.
"""
try:
if isinstance(offset_value, float):
offset_precise = Fraction(offset_value)
elif isinstance(offset_value, DfttTimecode):
offset_precise = offset_value.precise_timestamp
else:
offset_tc = DfttTimecode(offset_value, fps=self.__fps)
offset_precise = offset_tc.precise_timestamp
new_start = self.__start_precise_time + offset_precise
# Handle 24h constraint
if self.__strict_24h:
new_start = new_start % self.TIME_24H_SECONDS
logger.debug(
f"Offset timerange by {float(offset_precise):.3f}s: "
f"old_start={float(self.__start_precise_time):.3f}s, "
f"new_start={float(new_start):.3f}s"
)
return DfttTimeRange(
start_precise_time=new_start,
precise_duration=self.__precise_duration,
forward=self.__forward,
fps=self.__fps,
strict_24h=self.__strict_24h,
)
except Exception:
raise DFTTTimeRangeMethodError(f"Invalid offset value {offset_value}")
[docs]
def extend(self, extend_value: Union[int, float, DfttTimecode, str]) -> "DfttTimeRange":
"""Extend duration (positive value increases duration).
Extends or shortens the timerange by modifying the end point while
keeping the start point fixed. Positive values increase duration,
negative values decrease it.
Args:
extend_value: Amount to extend the duration. Can be:
- int or float: Seconds to extend (positive) or shorten (negative)
- DfttTimecode: Uses the timecode's timestamp
- str: Timecode string to parse
Returns:
DfttTimeRange: New timerange with modified duration
Raises:
DFTTTimeRangeValueError: If extension results in zero-length timerange
or exceeds 24 hours in strict mode
DFTTTimeRangeMethodError: If extend_value cannot be parsed
Examples:
>>> tr = DfttTimeRange('01:00:00:00', '01:10:00:00', fps=24)
>>> tr2 = tr.extend(300) # Add 5 minutes
>>> print(tr2.duration)
900.0
>>> tr3 = tr.extend(-300) # Subtract 5 minutes
>>> print(tr3.duration)
300.0
Note:
The direction (forward/backward) affects how extension is applied.
"""
try:
if isinstance(extend_value, (int, float)):
extend_precise = Fraction(extend_value)
elif isinstance(extend_value, DfttTimecode):
extend_precise = extend_value.precise_timestamp
else:
extend_tc = DfttTimecode(extend_value, fps=self.__fps)
extend_precise = extend_tc.precise_timestamp
new_duration = self.__precise_duration + (
extend_precise if self.__forward else -extend_precise
)
if new_duration == 0:
logger.error("Cannot create zero-length timerange via extend")
raise DFTTTimeRangeValueError("Cannot create zero-length timerange")
# Handle 24h constraint
if self.__strict_24h and abs(new_duration) > self.TIME_24H_SECONDS:
logger.error(
f"Extended duration {abs(new_duration):.3f}s exceeds 24 hours in strict mode"
)
raise DFTTTimeRangeValueError(
"Duration exceeds 24 hours in strict mode"
)
logger.debug(
f"Extend timerange by {float(extend_precise):.3f}s: "
f"old_duration={float(self.__precise_duration):.3f}s, "
f"new_duration={float(new_duration):.3f}s"
)
return DfttTimeRange(
start_precise_time=self.__start_precise_time,
precise_duration=new_duration,
forward=self.__forward,
fps=self.__fps,
strict_24h=self.__strict_24h,
)
except Exception as e:
if isinstance(e, DFTTTimeRangeValueError):
raise
raise DFTTTimeRangeMethodError("Invalid extend value")
[docs]
def shorten(self, shorten_value: Union[int, float, DfttTimecode, str]) -> "DfttTimeRange":
"""Shorten duration (positive value decreases duration).
This is a convenience method that calls :meth:`extend` with a negated value.
Shortens the timerange by modifying the end point while keeping the start fixed.
Args:
shorten_value: Amount to shorten the duration. Can be:
- int or float: Seconds to shorten (positive decreases duration)
- DfttTimecode: Uses the timecode's timestamp
- str: Timecode string to parse
Returns:
DfttTimeRange: New timerange with shortened duration
Raises:
DFTTTimeRangeValueError: If shortening results in zero-length timerange
DFTTTimeRangeMethodError: If shorten_value cannot be parsed
Examples:
>>> tr = DfttTimeRange('01:00:00:00', '01:10:00:00', fps=24)
>>> tr2 = tr.shorten(300) # Shorten by 5 minutes
>>> print(tr2.duration)
300.0
>>> print(tr2.end)
01:05:00:00
Note:
Internally calls ``extend(-shorten_value)`` for numeric values.
"""
return self.extend(
-shorten_value if isinstance(shorten_value, (int, float)) else shorten_value
)
[docs]
def reverse(self) -> "DfttTimeRange":
"""Reverse direction of timerange.
Creates a new timerange with swapped start/end points and inverted direction.
The duration magnitude remains the same, but the direction is flipped.
Returns:
DfttTimeRange: New timerange with reversed direction
Examples:
>>> tr = DfttTimeRange('01:00:00:00', '02:00:00:00', fps=24, forward=True)
>>> print(tr.start, '->', tr.end)
01:00:00:00 -> 02:00:00:00
>>> tr_rev = tr.reverse()
>>> print(tr_rev.start, '->', tr_rev.end)
02:00:00:00 -> 01:00:00:00
>>> print(tr_rev.forward)
False
>>> print(tr.duration == tr_rev.duration)
True
Note:
- The new start becomes the old end
- The forward flag is flipped
- Duration magnitude is preserved
- This is useful for working with timeranges that play backward
"""
logger.debug(
f"Reversing timerange: forward={self.__forward} -> {not self.__forward}"
)
return DfttTimeRange(
start_precise_time=self.end_precise_time,
precise_duration=self.__precise_duration,
forward=not self.__forward,
fps=self.__fps,
strict_24h=self.__strict_24h,
)
[docs]
def retime(self, retime_factor: Union[int, float, Fraction]) -> "DfttTimeRange":
"""Change duration by multiplication factor.
Scales the timerange duration by the given factor while keeping the start
point fixed. This is useful for time-stretching or speed-change operations.
Args:
retime_factor: Multiplication factor for the duration. Can be:
- int or float: Factor to multiply duration by
- Fraction: Precise rational factor
Examples: 2.0 doubles duration, 0.5 halves it, 1.5 extends by 50%
Returns:
DfttTimeRange: New timerange with scaled duration
Raises:
DFTTTimeRangeTypeError: If retime_factor is not numeric
DFTTTimeRangeValueError: If retime_factor is 0, or if result exceeds
24 hours in strict_24h mode
Examples:
>>> tr = DfttTimeRange('01:00:00:00', '01:10:00:00', fps=24)
>>> print(tr.duration)
600.0
>>> tr2 = tr.retime(2.0) # Double the duration
>>> print(tr2.duration)
1200.0
>>> print(tr2.end)
01:20:00:00
>>> tr3 = tr.retime(0.5) # Half the duration (speed up)
>>> print(tr3.duration)
300.0
>>> # Can also use * operator
>>> tr4 = tr * 2
>>> print(tr4.duration)
1200.0
Note:
- Start point remains unchanged
- Commonly used for speed ramping or time-stretching effects
- Factor > 1 increases duration (slow down)
- Factor < 1 decreases duration (speed up)
- Can also use the ``*`` operator for the same effect
"""
if not isinstance(retime_factor, (int, float, Fraction)):
logger.error(f"Retime factor must be numeric, got {type(retime_factor)}")
raise DFTTTimeRangeTypeError("Retime factor must be numeric")
if retime_factor == 0:
logger.error("Cannot retime to zero duration")
raise DFTTTimeRangeValueError("Cannot retime to zero duration")
new_duration = self.__precise_duration * Fraction(retime_factor)
if self.__strict_24h and abs(new_duration) > self.TIME_24H_SECONDS:
logger.error(
f"Retimed duration {abs(new_duration):.3f}s exceeds 24 hours in strict mode"
)
raise DFTTTimeRangeValueError("Duration exceeds 24 hours in strict mode")
logger.debug(
f"Retime timerange by factor {retime_factor}: "
f"old_duration={float(self.__precise_duration):.3f}s, "
f"new_duration={float(new_duration):.3f}s"
)
return DfttTimeRange(
start_precise_time=self.__start_precise_time,
precise_duration=new_duration,
forward=self.__forward,
fps=self.__fps,
strict_24h=self.__strict_24h,
)
[docs]
def separate(self, num_parts: int) -> List["DfttTimeRange"]:
"""Separate timerange into multiple equal parts.
Divides the timerange into a specified number of equal-duration sub-ranges.
All parts have the same duration and are contiguous (adjacent with no gaps).
Args:
num_parts: Number of parts to divide the timerange into (must be >= 2)
Returns:
List[DfttTimeRange]: List of timerange parts, ordered from start to end
Raises:
DFTTTimeRangeValueError: If num_parts is less than 2
Examples:
>>> tr = DfttTimeRange('01:00:00:00', '01:01:00:00', fps=24)
>>> parts = tr.separate(4) # Split into 4 equal parts
>>> len(parts)
4
>>> for i, part in enumerate(parts):
... print(f"Part {i+1}: {part.start} - {part.end}, duration={part.duration}")
Part 1: 01:00:00:00 - 01:00:15:00, duration=15.0
Part 2: 01:00:15:00 - 01:00:30:00, duration=15.0
Part 3: 01:00:30:00 - 01:00:45:00, duration=15.0
Part 4: 01:00:45:00 - 01:01:00:00, duration=15.0
>>> # Each part has equal duration
>>> all(part.duration == tr.duration / 4 for part in parts)
True
Note:
- Each part has duration = original_duration / num_parts
- Parts are contiguous (no gaps or overlaps)
- All parts inherit the same fps, forward direction, and strict_24h mode
- For backward timeranges, parts are still ordered from start to end
- Useful for splitting work into parallel chunks or creating segments
"""
if num_parts < 2:
logger.error(f"Cannot separate into {num_parts} parts, must be >= 2")
raise DFTTTimeRangeValueError("Must separate into at least 2 parts")
part_duration = self.__precise_duration / num_parts
logger.debug(
f"Separating timerange into {num_parts} parts, each with duration={float(part_duration):.3f}s"
)
parts = []
for i in range(num_parts):
part_start = self.__start_precise_time + (
i * part_duration if self.__forward else -i * part_duration
)
parts.append(
DfttTimeRange(
start_precise_time=part_start,
precise_duration=part_duration,
forward=self.__forward,
fps=self.__fps,
strict_24h=self.__strict_24h,
)
)
return parts
# Operations with other timeranges
[docs]
def contains(self, item: Union[DfttTimecode, 'DfttTimeRange', str, int, float], strict_forward: bool = False) -> bool:
"""Check if timerange contains another timerange or timecode.
Args:
item: Item to check for containment. Can be:
- DfttTimecode: Checks if timecode is within range
- DfttTimeRange: Checks if entire timerange is contained
- str, int, float: Converted to timecode for checking
strict_forward: If True, requires contained timerange to have same
direction. Only applies when item is a DfttTimeRange. Defaults to False.
Returns:
bool: True if item is contained within this timerange, False otherwise
Raises:
DFTTTimeRangeTypeError: If item cannot be converted to timecode
Examples:
>>> tr = DfttTimeRange('01:00:00:00', '02:00:00:00', fps=24)
>>> tc = DfttTimecode('01:30:00:00', fps=24)
>>> tr.contains(tc)
True
>>> tr.contains('00:30:00:00')
False
>>> tr2 = DfttTimeRange('01:10:00:00', '01:50:00:00', fps=24)
>>> tr.contains(tr2)
True
Note:
For backward timeranges, containment is checked accordingly.
"""
if isinstance(item, DfttTimecode):
item_time = item.precise_timestamp
start_time = self.__start_precise_time
end_time = self.end_precise_time
if self.__forward:
return start_time <= item_time <= end_time
else:
return end_time <= item_time <= start_time
elif isinstance(item, DfttTimeRange):
if strict_forward and item.forward != self.__forward:
return False
item_start = item.start_precise_time
item_end = item.end_precise_time
return self.contains(
DfttTimecode(float(item_start), fps=self.__fps)
) and self.contains(DfttTimecode(float(item_end), fps=self.__fps))
else:
try:
tc = DfttTimecode(item, fps=self.__fps)
return self.contains(tc)
except DFTTError:
raise DFTTTimeRangeTypeError("Invalid item type for contains check")
[docs]
def intersect(self, other: "DfttTimeRange") -> Optional["DfttTimeRange"]:
"""Calculate intersection of two timeranges (AND operation).
Returns the overlapping portion of two timeranges. Both timeranges must
have the same direction and frame rate.
Args:
other: Another DfttTimeRange to intersect with
Returns:
DfttTimeRange: New timerange representing the intersection, or None if no overlap
Raises:
DFTTTimeRangeTypeError: If other is not a DfttTimeRange
DFTTTimeRangeMethodError: If timeranges have different directions
DFTTTimeRangeFPSError: If timeranges have different frame rates
Examples:
>>> tr1 = DfttTimeRange('01:00:00:00', '02:00:00:00', fps=24)
>>> tr2 = DfttTimeRange('01:30:00:00', '02:30:00:00', fps=24)
>>> intersection = tr1.intersect(tr2)
>>> print(intersection.start)
01:30:00:00
>>> print(intersection.end)
02:00:00:00
>>> # Can also use & operator
>>> intersection = tr1 & tr2
Note:
- Returns None if timeranges don't overlap
- Strict_24h is True only if both input timeranges have it enabled
"""
if not isinstance(other, DfttTimeRange):
logger.error(f"Can only intersect with DfttTimeRange, got {type(other)}")
raise DFTTTimeRangeTypeError(
"Can only intersect with another DfttTimeRange"
)
if self.__forward != other.forward:
logger.error(
f"Cannot intersect timeranges with different directions: "
f"self.forward={self.__forward}, other.forward={other.forward}"
)
raise DFTTTimeRangeMethodError(
"Cannot intersect timeranges with different directions"
)
if self.__fps != other.fps:
logger.error(
f"Cannot intersect timeranges with different FPS: "
f"self.fps={self.__fps}, other.fps={other.fps}"
)
raise DFTTTimeRangeFPSError(
"Cannot intersect timeranges with different FPS"
)
# Calculate intersection bounds
if self.__forward:
start = max(self.__start_precise_time, other.start_precise_time)
end = min(self.end_precise_time, other.end_precise_time)
else:
start = min(self.__start_precise_time, other.start_precise_time)
end = max(self.end_precise_time, other.end_precise_time)
if (self.__forward and start >= end) or (not self.__forward and start <= end):
logger.debug("No intersection found between timeranges")
return None # No intersection
duration = end - start if self.__forward else start - end
logger.debug(
f"Intersection found: start={float(start):.3f}s, duration={float(duration):.3f}s"
)
return DfttTimeRange(
start_precise_time=start,
precise_duration=duration,
forward=self.__forward,
fps=self.__fps,
strict_24h=self.__strict_24h and other.strict_24h,
)
[docs]
def union(self, other: "DfttTimeRange") -> "DfttTimeRange":
"""Calculate union of two timeranges (OR operation).
Combines two overlapping or adjacent timeranges into a single continuous
timerange that spans from the earliest start to the latest end. Both
timeranges must have the same direction and frame rate, and must either
overlap or be adjacent (touching) with no gap between them.
Args:
other: Another DfttTimeRange to union with
Returns:
DfttTimeRange: New timerange spanning both input ranges
Raises:
DFTTTimeRangeTypeError: If other is not a DfttTimeRange
DFTTTimeRangeMethodError: If timeranges have different directions,
or if they are non-overlapping and non-adjacent (have a gap)
DFTTTimeRangeFPSError: If timeranges have different frame rates
Examples:
Overlapping timeranges::
>>> tr1 = DfttTimeRange('01:00:00:00', '01:30:00:00', fps=24)
>>> tr2 = DfttTimeRange('01:20:00:00', '02:00:00:00', fps=24)
>>> union = tr1.union(tr2)
>>> print(union.start)
01:00:00:00
>>> print(union.end)
02:00:00:00
>>> print(union.duration)
3600.0
Adjacent (touching) timeranges::
>>> tr1 = DfttTimeRange('01:00:00:00', '01:30:00:00', fps=24)
>>> tr2 = DfttTimeRange('01:30:00:00', '02:00:00:00', fps=24)
>>> union = tr1.union(tr2) # No gap, they touch
>>> print(union.start)
01:00:00:00
>>> print(union.end)
02:00:00:00
Using the | operator::
>>> tr1 = DfttTimeRange('01:00:00:00', '01:30:00:00', fps=24)
>>> tr2 = DfttTimeRange('01:20:00:00', '02:00:00:00', fps=24)
>>> union = tr1 | tr2 # Shorthand for union
>>> print(union.duration)
3600.0
Non-adjacent timeranges (will fail)::
>>> tr1 = DfttTimeRange('01:00:00:00', '01:30:00:00', fps=24)
>>> tr2 = DfttTimeRange('02:00:00:00', '02:30:00:00', fps=24)
>>> union = tr1.union(tr2) # Gap of 30 minutes
DFTTTimeRangeMethodError: Cannot union non-overlapping, non-adjacent timeranges
Note:
- Timeranges must overlap or be adjacent (no gap allowed)
- The result spans from earliest start to latest end
- Direction must be the same for both timeranges
- Strict_24h is True only if both input timeranges have it enabled
- This is a set operation, different from :meth:`add` which combines durations
- Can also use the ``|`` operator as a shorthand
- For checking overlap, use :meth:`intersect` first
See Also:
- :meth:`intersect`: Get the overlapping portion (AND operation)
- :meth:`add`: Add durations (different from union)
"""
if not isinstance(other, DfttTimeRange):
logger.error(f"Can only union with DfttTimeRange, got {type(other)}")
raise DFTTTimeRangeTypeError("Can only union with another DfttTimeRange")
if self.__forward != other.forward:
logger.error(
f"Cannot union timeranges with different directions: "
f"self.forward={self.__forward}, other.forward={other.forward}"
)
raise DFTTTimeRangeMethodError(
"Cannot union timeranges with different directions"
)
if self.__fps != other.fps:
logger.error(
f"Cannot union timeranges with different FPS: "
f"self.fps={self.__fps}, other.fps={other.fps}"
)
raise DFTTTimeRangeFPSError("Cannot union timeranges with different FPS")
# Check for overlap or adjacency
if self.intersect(other) is None:
# Check if they are adjacent
if self.__forward:
if not (
self.end_precise_time == other.start_precise_time
or other.end_precise_time == self.__start_precise_time
):
logger.error(
"Cannot union non-overlapping, non-adjacent timeranges: "
f"self=[{float(self.__start_precise_time):.3f}s, {float(self.end_precise_time):.3f}s], "
f"other=[{float(other.start_precise_time):.3f}s, {float(other.end_precise_time):.3f}s]"
)
raise DFTTTimeRangeMethodError(
"Cannot union non-overlapping, non-adjacent timeranges"
)
else:
if not (
self.end_precise_time == other.start_precise_time
or other.end_precise_time == self.__start_precise_time
):
logger.error(
"Cannot union non-overlapping, non-adjacent timeranges (backward)"
)
raise DFTTTimeRangeMethodError(
"Cannot union non-overlapping, non-adjacent timeranges"
)
# Calculate union bounds
if self.__forward:
start = min(self.__start_precise_time, other.start_precise_time)
end = max(self.end_precise_time, other.end_precise_time)
else:
start = max(self.__start_precise_time, other.start_precise_time)
end = min(self.end_precise_time, other.end_precise_time)
duration = end - start if self.__forward else start - end
logger.debug(
f"Union created: start={float(start):.3f}s, duration={float(duration):.3f}s"
)
return DfttTimeRange(
start_precise_time=start,
precise_duration=duration,
forward=self.__forward,
fps=self.__fps,
strict_24h=self.__strict_24h and other.strict_24h,
)
[docs]
def add(self, other: "DfttTimeRange") -> "DfttTimeRange":
"""Add durations of two timeranges (direction-sensitive).
Combines the durations of two timeranges to create a new timerange with
extended duration. The behavior depends on whether the timeranges have
the same or opposite directions.
Args:
other: Another DfttTimeRange to add
Returns:
DfttTimeRange: New timerange with combined duration, same start point
and direction as the original
Raises:
DFTTTimeRangeTypeError: If other is not a DfttTimeRange
DFTTTimeRangeFPSError: If timeranges have different frame rates
DFTTTimeRangeValueError: If result is zero-length
Examples:
>>> tr1 = DfttTimeRange('01:00:00:00', '01:10:00:00', fps=24) # 10 min
>>> tr2 = DfttTimeRange('01:00:00:00', '01:05:00:00', fps=24) # 5 min
>>> tr3 = tr1.add(tr2)
>>> print(tr3.duration) # Same direction: 10 + 5 = 15 min
900.0
>>> tr4 = DfttTimeRange('01:10:00:00', '01:00:00:00', forward=False, fps=24)
>>> tr5 = tr1.add(tr4)
>>> print(tr5.duration) # Opposite direction: 10 - 10 = 0 (error)
DFTTTimeRangeValueError
Note:
- Same direction: durations are added (extend)
- Opposite direction: durations are subtracted (shorten)
- Start point remains unchanged
- This is different from :meth:`union` which combines overlapping ranges
"""
if not isinstance(other, DfttTimeRange):
logger.error(f"Can only add DfttTimeRange, got {type(other)}")
raise DFTTTimeRangeTypeError("Can only add another DfttTimeRange")
if self.__fps != other.fps:
logger.error(
f"Cannot add timeranges with different FPS: "
f"self.fps={self.__fps}, other.fps={other.fps}"
)
raise DFTTTimeRangeFPSError("Cannot add timeranges with different FPS")
# Direction sensitive addition
if self.__forward == other.forward:
new_duration = self.__precise_duration + other.precise_duration
else:
new_duration = self.__precise_duration - other.precise_duration
if new_duration == 0:
logger.error("Add operation resulted in zero-length timerange")
raise DFTTTimeRangeValueError("Cannot create zero-length timerange")
logger.debug(
f"Add timerange: same_direction={self.__forward == other.forward}, "
f"old_duration={float(self.__precise_duration):.3f}s, "
f"new_duration={float(new_duration):.3f}s"
)
return DfttTimeRange(
start_precise_time=self.__start_precise_time,
precise_duration=new_duration,
forward=self.__forward,
fps=self.__fps,
strict_24h=self.__strict_24h,
)
[docs]
def subtract(self, other: "DfttTimeRange") -> "DfttTimeRange":
"""Subtract durations of two timeranges (direction-sensitive).
Subtracts the duration of another timerange from this one to create a new
timerange with reduced duration. The behavior depends on whether the
timeranges have the same or opposite directions.
Args:
other: Another DfttTimeRange to subtract
Returns:
DfttTimeRange: New timerange with reduced duration, same start point
and direction as the original
Raises:
DFTTTimeRangeTypeError: If other is not a DfttTimeRange
DFTTTimeRangeFPSError: If timeranges have different frame rates
DFTTTimeRangeValueError: If result is zero-length
Examples:
>>> tr1 = DfttTimeRange('01:00:00:00', '01:10:00:00', fps=24) # 10 min
>>> tr2 = DfttTimeRange('01:00:00:00', '01:03:00:00', fps=24) # 3 min
>>> tr3 = tr1.subtract(tr2)
>>> print(tr3.duration) # Same direction: 10 - 3 = 7 min
420.0
>>> print(tr3.end)
01:07:00:00
>>> # With opposite directions
>>> tr4 = DfttTimeRange('01:10:00:00', '01:08:00:00', forward=False, fps=24)
>>> tr5 = tr1.subtract(tr4) # 10 - (-2) = 12 min
>>> print(tr5.duration)
720.0
Note:
- Same direction: durations are subtracted (shorten)
- Opposite direction: durations are added (extend)
- Start point remains unchanged
- This is the inverse operation of :meth:`add`
- Can result in zero-length error if durations are equal
"""
if not isinstance(other, DfttTimeRange):
logger.error(f"Can only subtract DfttTimeRange, got {type(other)}")
raise DFTTTimeRangeTypeError("Can only subtract another DfttTimeRange")
if self.__fps != other.fps:
logger.error(
f"Cannot subtract timeranges with different FPS: "
f"self.fps={self.__fps}, other.fps={other.fps}"
)
raise DFTTTimeRangeFPSError("Cannot subtract timeranges with different FPS")
# Direction sensitive subtraction
if self.__forward == other.forward:
new_duration = self.__precise_duration - other.precise_duration
else:
new_duration = self.__precise_duration + other.precise_duration
if new_duration == 0:
logger.error("Subtract operation resulted in zero-length timerange")
raise DFTTTimeRangeValueError("Cannot create zero-length timerange")
logger.debug(
f"Subtract timerange: same_direction={self.__forward == other.forward}, "
f"old_duration={float(self.__precise_duration):.3f}s, "
f"new_duration={float(new_duration):.3f}s"
)
return DfttTimeRange(
start_precise_time=self.__start_precise_time,
precise_duration=new_duration,
forward=self.__forward,
fps=self.__fps,
strict_24h=self.__strict_24h,
)
# Magic methods and utilities
[docs]
def __str__(self) -> str:
return f"DfttTimeRange({self.start},{self.end},fps={self.__fps},forward={self.__forward})"
[docs]
def __repr__(self) -> str:
return f"DfttTimeRange(start_precise_time={self.__start_precise_time}, precise_duration={self.__precise_duration}, forward={self.__forward}, fps={self.__fps}, strict_24h={self.__strict_24h})"
def __len__(self) -> int:
return self.framecount
[docs]
def __contains__(self, item) -> bool:
return self.contains(item)
[docs]
def __iter__(self) -> Iterator[DfttTimecode]:
"""Iterate through timecodes in the range"""
current_time = self.__start_precise_time
frame_duration = Fraction(1) / self.__fps
if self.__forward:
while current_time < self.end_precise_time:
yield DfttTimecode(float(current_time), fps=self.__fps)
current_time += frame_duration
else:
while current_time > self.end_precise_time:
yield DfttTimecode(float(current_time), fps=self.__fps)
current_time -= frame_duration
[docs]
def __add__(self, other) -> "DfttTimeRange":
"""Add operator for timeranges"""
if isinstance(other, DfttTimeRange):
return self.add(other)
else:
# Treat as offset
return self.offset(other)
[docs]
def __sub__(self, other) -> "DfttTimeRange":
"""Subtract operator for timeranges"""
if isinstance(other, DfttTimeRange):
return self.subtract(other)
else:
# Treat as negative offset
return self.offset(-other if isinstance(other, (int, float)) else other)
[docs]
def __mul__(self, factor) -> "DfttTimeRange":
"""Multiply duration by factor"""
return self.retime(factor)
[docs]
def __truediv__(self, factor) -> "DfttTimeRange":
"""Divide duration by factor"""
return self.retime(1 / factor)
[docs]
def __and__(self, other) -> Optional["DfttTimeRange"]:
"""Intersection operator"""
return self.intersect(other)
[docs]
def __or__(self, other) -> "DfttTimeRange":
"""Union operator"""
return self.union(other)
[docs]
def __eq__(self, other) -> bool:
"""Equality comparison"""
if not isinstance(other, DfttTimeRange):
return False
return (
self.__start_precise_time == other.start_precise_time
and self.__precise_duration == other.precise_duration
and self.__forward == other.forward
and self.__fps == other.fps
)
[docs]
def __ne__(self, other) -> bool:
"""Inequality comparison"""
return not self.__eq__(other)
[docs]
def __lt__(self, other) -> bool:
"""Less than comparison based on start time"""
if not isinstance(other, DfttTimeRange):
raise DFTTTimeRangeTypeError("Cannot compare with non-DfttTimeRange")
return self.__start_precise_time < other.start_precise_time
[docs]
def __le__(self, other) -> bool:
"""Less than or equal comparison"""
return self.__lt__(other) or self.__eq__(other)
[docs]
def __gt__(self, other) -> bool:
"""Greater than comparison"""
if not isinstance(other, DfttTimeRange):
raise DFTTTimeRangeTypeError("Cannot compare with non-DfttTimeRange")
return self.__start_precise_time > other.start_precise_time
[docs]
def __ge__(self, other) -> bool:
"""Greater than or equal comparison"""
return self.__gt__(other) or self.__eq__(other)