Source code for camtasia.timeline.timeline

"""Timeline — top-level container for tracks, markers, and scene data."""
from __future__ import annotations

from dataclasses import dataclass
from typing import Any, Iterator, TYPE_CHECKING

if TYPE_CHECKING:
    from camtasia.timeline.captions import CaptionAttributes
    from camtasia.timeline.clips.group import Group

from camtasia.timeline.clips import BaseClip
from camtasia.timeline.markers import Marker, MarkerList
from camtasia.timeline.track import Track
from camtasia.timing import seconds_to_ticks, ticks_to_seconds
from camtasia.types import TimelineSummary


[docs] @dataclass class ZoomPanKeyframe: """A zoom/pan keyframe on the timeline.""" time_seconds: float scale: float = 1.0 center_x: float = 0.5 # 0.0-1.0 normalized center_y: float = 0.5 # 0.0-1.0 normalized def __repr__(self) -> str: return (f'ZoomPanKeyframe(t={self.time_seconds:.2f}s, ' f'scale={self.scale:.2f}, center=({self.center_x:.2f}, {self.center_y:.2f}))')
def _remap_clip_ids_recursive(clip_data: dict, id_counter: list[int]) -> None: """Recursively remap all 'id' fields in a clip and its nested children.""" if 'id' in clip_data: clip_data['id'] = id_counter[0] id_counter[0] += 1 for key in ('video', 'audio'): if key in clip_data and isinstance(clip_data[key], dict): _remap_clip_ids_recursive(clip_data[key], id_counter) for track in clip_data.get('tracks', []): for media in track.get('medias', []): _remap_clip_ids_recursive(media, id_counter) for media in clip_data.get('medias', []): _remap_clip_ids_recursive(media, id_counter) # pragma: no cover
[docs] class Timeline: """Represents the timeline of a Camtasia project. Args: timeline_data: The ``timeline`` sub-dict from the project JSON. """ def __init__(self, timeline_data: dict[str, Any]) -> None: self._data = timeline_data def __repr__(self) -> str: return f'Timeline(tracks={self.track_count})' def __len__(self) -> int: return self.track_count # ------------------------------------------------------------------ # Tracks # ------------------------------------------------------------------ @property def tracks(self) -> _TrackAccessor: """Iterable accessor over Track objects.""" return _TrackAccessor(self._data) @property def track_count(self) -> int: """Number of tracks on the timeline.""" return len(self._track_list) @property def total_clip_count(self) -> int: """Total number of clips across all tracks.""" return sum(len(track) for track in self.tracks) @property def has_clips(self) -> bool: """Whether any track has clips.""" return self.total_clip_count > 0 @property def total_transition_count(self) -> int: """Total number of transitions across all tracks.""" return sum(track.transition_count for track in self.tracks)
[docs] def add_track(self, name: str = '') -> Track: """Append a new empty track. Args: name: Display name for the track. Returns: The newly created Track. """ index = len(self._track_list) return self.tracks.insert_track(index, name)
[docs] def remove_track(self, index: int) -> None: """Remove a track by its index and re-number remaining tracks. Args: index: The track index to remove. Raises: KeyError: No track with the given index. """ del self.tracks[index]
[docs] def remove_tracks_by_name(self, name: str) -> int: """Remove all tracks with the given name. Returns count removed.""" indices = [t.index for t in self.tracks if t.name == name] for idx in reversed(indices): self.remove_track(idx) return len(indices)
[docs] def next_clip_id(self) -> int: """Return the next available clip ID across ALL tracks. Scans every track — including nested group tracks and UnifiedMedia sub-clips — for the maximum clip ID and returns ``max + 1``. Returns ``1`` for an empty project. """ from camtasia.timeline.track import _max_clip_id return _max_clip_id(self._track_list) + 1
[docs] def duplicate_track(self, source_track_index: int) -> Track: """Duplicate a track and all its clips. Returns the new track.""" import copy source_track_data: dict[str, Any] = self._track_list[source_track_index] source_attrs: dict[str, Any] = self._data['trackAttributes'][source_track_index] duplicated_track_data: dict[str, Any] = copy.deepcopy(source_track_data) duplicated_attrs: dict[str, Any] = copy.deepcopy(source_attrs) duplicated_attrs['ident'] = f"{duplicated_attrs.get('ident', '')} (copy)" # Remap clip IDs to avoid collisions (recursively, including nested clips) next_id: int = self.next_clip_id() id_counter: list[int] = [next_id] for media_dict in duplicated_track_data.get('medias', []): _remap_clip_ids_recursive(media_dict, id_counter) # Insert after source insert_index: int = source_track_index + 1 self._track_list.insert(insert_index, duplicated_track_data) self._data['trackAttributes'].insert(insert_index, duplicated_attrs) # Fix track indices for track_index, track_data in enumerate(self._track_list): track_data['trackIndex'] = track_index return Track(duplicated_attrs, duplicated_track_data)
[docs] def move_track(self, from_index: int, to_index: int) -> None: """Move a track from one array position to another. Args: from_index: Current array position. to_index: Desired array position. """ self.tracks.move_track(from_index, to_index)
[docs] def move_track_to_back(self, track_index: int) -> None: """Move a track to position 0 (behind all other tracks).""" self.move_track(track_index, 0)
[docs] def move_track_to_front(self, track_index: int) -> None: """Move a track to the last position (in front of all other tracks).""" self.move_track(track_index, len(self) - 1)
[docs] def find_clip(self, clip_id: int) -> tuple | None: """Find a clip by ID across all tracks. Returns (track, clip) tuple, or None. """ for track in self.tracks: result = track.find_clip(clip_id) if result is not None: return (track, result) return None
[docs] def reorder_tracks(self, order: list[int]) -> None: """Reorder tracks by providing current trackIndex values in desired order. Args: order: List of current ``trackIndex`` values in the new order. """ self.tracks.reorder_tracks(order)
# ------------------------------------------------------------------ # Markers # ------------------------------------------------------------------ @property def markers(self) -> _TimelineMarkers: """Timeline-level markers (from ``parameters.toc``).""" return _TimelineMarkers(self._data) # ------------------------------------------------------------------ # L2 convenience methods # ------------------------------------------------------------------ @property def total_duration_ticks(self) -> int: """Maximum end time across all tracks, in ticks. Returns: The tick position of the latest clip end, or 0 if empty. """ return max( (track.end_time_ticks() for track in self.tracks), default=0, )
[docs] def total_duration_seconds(self) -> float: """Maximum end time across all tracks, in seconds. Returns: Duration in seconds, or 0.0 if the timeline is empty. """ return ticks_to_seconds(self.total_duration_ticks)
@property def duration_seconds(self) -> float: """Total timeline duration in seconds.""" return self.total_duration_seconds() @property def total_duration_formatted(self) -> str: """Total timeline duration as HH:MM:SS or MM:SS string.""" total_seconds: float = self.total_duration_seconds() hours: int = int(total_seconds // 3600) minutes: int = int((total_seconds % 3600) // 60) remaining_seconds: int = int(total_seconds % 60) if hours > 0: return f'{hours}:{minutes:02d}:{remaining_seconds:02d}' return f'{minutes}:{remaining_seconds:02d}'
[docs] def summary(self) -> str: """Human-readable timeline summary.""" lines: list[str] = [ f'Timeline: {self.total_duration_formatted}', f'Tracks: {len(list(self.tracks))}', f'Total clips: {sum(len(t) for t in self.tracks)}', f'Clip density: {self.clip_density:.2f}', ] groups = self.groups if groups: lines.append(f'Groups: {len(groups)}') return '\n'.join(lines)
@property def end_seconds(self) -> float: """End time of the timeline in seconds.""" return ticks_to_seconds(self.total_duration_ticks) @property def track_summary(self) -> list[dict[str, Any]]: """Summary of each track as a list of dicts.""" return [ { 'name': track.name, 'index': track.index, 'clip_count': len(track), 'duration_seconds': track.total_duration_seconds, 'is_empty': track.is_empty, } for track in self.tracks ]
[docs] def describe(self) -> str: """Human-readable timeline description.""" lines = [ f'Timeline: {self.track_count} tracks, {self.total_clip_count} clips, {self.total_duration_seconds():.1f}s', '', ] for track in self.tracks: lines.append(track.describe()) lines.append('') return '\n'.join(lines)
[docs] def get_or_create_track(self, name: str) -> Track: """Find a track by name, or create a new one if it doesn't exist. Args: name: Display name to search for (exact match). Returns: The existing or newly created Track. """ for track in self.tracks: if track.name == name: return track return self.add_track(name)
[docs] def all_clips(self) -> list[BaseClip]: """All clips across all tracks, including nested clips inside Groups/StitchedMedia/UnifiedMedia.""" from typing import Iterable result: list[BaseClip] = [] def _collect(clips: Iterable[BaseClip]) -> None: for clip in clips: result.append(clip) if clip.clip_type == 'Group': from camtasia.timeline.clips.group import Group if isinstance(clip, Group): for gt in clip.tracks: _collect(gt.clips) elif clip.clip_type == 'StitchedMedia': from camtasia.timeline.clips import clip_from_dict for nested in clip._data.get('medias', []): result.append(clip_from_dict(nested)) elif clip.clip_type == 'UnifiedMedia': from camtasia.timeline.clips import clip_from_dict if 'video' in clip._data: result.append(clip_from_dict(clip._data['video'])) if 'audio' in clip._data: result.append(clip_from_dict(clip._data['audio'])) for track in self.tracks: _collect(track.clips) return result
@property def groups(self) -> list[Group]: """All Group clips across all tracks, including nested groups.""" from camtasia.timeline.clips.group import Group return [clip for clip in self.all_clips() if isinstance(clip, Group)]
[docs] def find_track(self, name: str) -> Track | None: """Find a track by name, or return None.""" for track in self.tracks: if track.name == name: return track return None
@property def empty_tracks(self) -> list[Track]: """Return all tracks with no clips.""" return [t for t in self.tracks if t.is_empty]
[docs] def find_track_by_name(self, track_name: str) -> Track | None: """Find the first track with the given name, or None. Args: track_name: Exact track name to search for. Returns: The first matching Track, or None if no track has that name. """ for track in self.tracks: if track.name == track_name: return track return None
@property def tracks_with_clips(self) -> list[Track]: """Return only tracks that have clips.""" return [t for t in self.tracks if not t.is_empty] @property def track_names(self) -> list[str]: """Names of all tracks.""" return [t.name for t in self.tracks]
[docs] def to_dict(self) -> TimelineSummary: """Return a summary dict of the timeline structure.""" return { 'track_count': self.track_count, 'total_clip_count': self.total_clip_count, 'duration_seconds': self.total_duration_seconds(), 'has_clips': self.has_clips, 'track_names': self.track_names, }
@property def all_clip_ids(self) -> set[int]: """Set of all clip IDs across all tracks.""" return {c.id for c in self.all_clips()} @property def all_effects(self) -> list[tuple[Track, BaseClip, dict]]: """All effects across all tracks as (track, clip, effect_dict) tuples.""" from typing import Iterable results: list[tuple[Track, BaseClip, dict]] = [] def _collect(track: Track, clips: Iterable[BaseClip]) -> None: for clip in clips: for eff in clip._data.get('effects', []): results.append((track, clip, eff)) if clip.clip_type == 'Group': from camtasia.timeline.clips.group import Group if isinstance(clip, Group): for gt in clip.tracks: _collect(track, gt.clips) elif clip.clip_type == 'StitchedMedia': from camtasia.timeline.clips import clip_from_dict for nested in clip._data.get('medias', []): nc = clip_from_dict(nested) for eff in nc._data.get('effects', []): results.append((track, nc, eff)) elif clip.clip_type == 'UnifiedMedia': from camtasia.timeline.clips import clip_from_dict for key in ('video', 'audio'): if key in clip._data: nc = clip_from_dict(clip._data[key]) for eff in nc._data.get('effects', []): results.append((track, nc, eff)) for track in self.tracks: _collect(track, track.clips) return results
[docs] def remove_all_transitions(self) -> int: """Remove all transitions from all tracks. Returns count removed.""" count = 0 for track in self.tracks: transitions = track._data.get('transitions', []) count += len(transitions) track._data['transitions'] = [] return count
[docs] def remove_empty_tracks(self) -> int: """Remove all empty tracks. Returns count removed.""" empty_indices = [t.index for t in self.tracks if t.is_empty] for idx in reversed(empty_indices): self.remove_track(idx) return len(empty_indices)
[docs] def remove_all_empty_tracks(self) -> int: """Remove every track that contains no clips. Returns: Number of tracks removed. """ return self.remove_empty_tracks()
[docs] def pack_all_tracks(self) -> int: """Pack every track, removing intra-track gaps between clips. Calls :func:`camtasia.operations.layout.pack_track` on each track so clips are repositioned end-to-end with no gaps. Returns: Number of tracks packed (tracks with at least one clip). """ from camtasia.operations.layout import pack_track packed_count: int = 0 for track in self.tracks: if not track.is_empty: pack_track(track) packed_count += 1 return packed_count
[docs] def remove_short_clips_all_tracks(self, minimum_duration_seconds: float) -> int: """Remove short clips from all tracks. Returns total count removed.""" total_removed: int = 0 for track in self.tracks: total_removed += track.remove_short_clips(minimum_duration_seconds) return total_removed
[docs] def clear_all(self) -> None: """Clear all clips and transitions from all tracks.""" for track in self.tracks: track.clear()
[docs] def add_marker(self, label: str, time_seconds: float) -> Marker: """Add a timeline marker at the given time. Args: label: Display label for the marker. time_seconds: Position in seconds. Returns: The newly created Marker. """ return self.markers.add(label, seconds_to_ticks(time_seconds))
# ------------------------------------------------------------------ # Search & filter # ------------------------------------------------------------------
[docs] def clips_in_range( self, start_seconds: float, end_seconds: float, ) -> list[tuple[Track, BaseClip]]: """Find all clips that overlap with a time range. Returns (track, clip) tuples for clips whose time span overlaps [start_seconds, end_seconds]. """ start_ticks = seconds_to_ticks(start_seconds) end_ticks = seconds_to_ticks(end_seconds) results = [] for track in self.tracks: for clip in track.clips: clip_start = clip.start clip_end = clip_start + clip.duration if clip_start < end_ticks and clip_end > start_ticks: results.append((track, clip)) return results
[docs] def find_all_clips_at(self, time_seconds: float) -> list[tuple[Track, BaseClip]]: """Find all clips at a time point across all tracks.""" results = [] for track in self.tracks: for clip in track.clips_at(time_seconds): results.append((track, clip)) return results
[docs] def clips_of_type(self, clip_type: str) -> list[tuple[Track, BaseClip]]: """Find all clips of a specific type across all tracks. Args: clip_type: Clip type string (e.g. 'AMFile', 'IMFile', 'Group'). Returns: List of (track, clip) tuples. """ results = [] for track in self.tracks: for clip in track.clips: if clip.clip_type == clip_type: results.append((track, clip)) return results
@property def audio_clips(self) -> list[tuple[Track, BaseClip]]: """All audio clips across all tracks.""" return self.clips_of_type('AMFile') @property def image_clips(self) -> list[tuple[Track, BaseClip]]: """All image clips across all tracks.""" return self.clips_of_type('IMFile') @property def video_clips(self) -> list[tuple[Track, BaseClip]]: """All video clips across all tracks.""" return self.clips_of_type('VMFile') # ------------------------------------------------------------------ # Zoom & Pan # ------------------------------------------------------------------ @property def zoom_pan_keyframes(self) -> list[ZoomPanKeyframe]: """Get zoom/pan keyframes from the timeline.""" return [ ZoomPanKeyframe( time_seconds=ticks_to_seconds(kf.get('time', 0)), scale=kf.get('scale', 1.0), center_x=kf.get('centerX', 0.5), center_y=kf.get('centerY', 0.5), ) for kf in self._data.get('zoomNPan', []) ]
[docs] def add_zoom_pan( self, time_seconds: float, *, scale: float = 1.0, center_x: float = 0.5, center_y: float = 0.5, ) -> ZoomPanKeyframe: """Add a zoom/pan keyframe to the timeline. Args: time_seconds: Timeline position. scale: Zoom level (1.0 = 100%, 2.0 = 200%). center_x: Horizontal center 0.0-1.0 (0.5 = center). center_y: Vertical center 0.0-1.0 (0.5 = center). """ if scale <= 0: raise ValueError(f'Scale must be positive, got {scale}') self._data.setdefault('zoomNPan', []).append({ 'time': seconds_to_ticks(time_seconds), 'scale': scale, 'centerX': center_x, 'centerY': center_y, }) return ZoomPanKeyframe(time_seconds, scale, center_x, center_y)
[docs] def clear_zoom_pan(self) -> None: """Remove all zoom/pan keyframes.""" self._data['zoomNPan'] = []
# ------------------------------------------------------------------ # Audio / visual top-level properties # ------------------------------------------------------------------ @property def gain(self) -> float: """Audio gain level for the timeline.""" return float(self._data.get('gain', 1.0)) @gain.setter def gain(self, value: float) -> None: """Set the audio gain level for the timeline.""" self._data['gain'] = value @property def legacy_attenuate_audio_mix(self) -> bool: """Whether legacy audio attenuation mixing is enabled.""" return bool(self._data.get('legacyAttenuateAudioMix', True)) @property def background_color(self) -> list[int]: """Background color as an RGBA list.""" return self._data.get('backgroundColor', [0, 0, 0, 255]) # type: ignore[no-any-return] @background_color.setter def background_color(self, value: list[int]) -> None: """Set the background color as an RGBA list.""" self._data['backgroundColor'] = value # ------------------------------------------------------------------ # Caption attributes # ------------------------------------------------------------------ @property def caption_attributes(self) -> CaptionAttributes: """Caption styling configuration.""" from camtasia.timeline.captions import CaptionAttributes return CaptionAttributes(self._data.setdefault('captionAttributes', {})) # ------------------------------------------------------------------ # Structural validation # ------------------------------------------------------------------
[docs] def validate_structure(self) -> list[str]: """Check timeline structural invariants. Returns a list of issue descriptions (empty = valid). """ issues = [] # Check 1: trackIndex matches array position for i, track in enumerate(self.tracks): if track.index != i: issues.append(f'Track array[{i}] has trackIndex={track.index}') # Check 2: No duplicate clip IDs across all tracks (recursive) all_ids: dict[int, str] = {} for clip in self.all_clips(): if clip.id in all_ids: issues.append( f'Duplicate clip ID {clip.id} ' f'(also on {all_ids[clip.id]})' ) all_ids[clip.id] = 'timeline' # Check 3: No stale transition references (recursive clip IDs) all_clip_ids = {c.id for c in self.all_clips()} for track in self.tracks: for trans in track.transitions: if trans.left_media_id and trans.left_media_id not in all_clip_ids: issues.append( f'Track {track.index}: transition leftMedia={trans.left_media_id} ' f'not found in clips' ) if trans.right_media_id and trans.right_media_id not in all_clip_ids: issues.append( f'Track {track.index}: transition rightMedia={trans.right_media_id} ' f'not found in clips' ) # Check 4: No overlapping clips on the same track for track in self.tracks: for a_id, b_id in track.overlaps(): issues.append( f'Track {track.index}: clips {a_id} and {b_id} overlap' ) return issues
[docs] def flatten_to_track(self, target_track_name: str = 'Flattened') -> Track: """Copy all clips from all tracks onto a single target track. Creates a new track and copies all clips to it. Original tracks are not modified. Clips keep their original timing. Args: target_track_name: Name for the target track. Returns: The target track with all clips. """ import copy target = self.add_track(target_track_name) target_idx = target.index next_id = [self.next_clip_id()] for track in self.tracks: if track.index == target_idx: continue for m in track._data.get('medias', []): new_clip = copy.deepcopy(m) _remap_clip_ids_recursive(new_clip, next_id) target._data.setdefault('medias', []).append(new_clip) return target
[docs] def shift_all(self, seconds: float) -> None: """Shift all clips on all tracks by the given number of seconds. Positive values move clips forward, negative moves backward. Clips are clamped to not go before time 0. """ offset = seconds_to_ticks(seconds) for track in self.tracks: for m in track._data.get('medias', []): new_start = m.get('start', 0) + offset m['start'] = max(0, new_start)
[docs] def apply_to_all_clips(self, fn) -> int: """Apply a function to every clip on every track. Returns count.""" count = 0 for track in self.tracks: count += track.apply_to_all(fn) return count
[docs] def reverse_track_order(self) -> None: """Reverse the order of all tracks.""" tracks = self._data['sceneTrack']['scenes'][0]['csml']['tracks'] attrs = self._data['trackAttributes'] tracks.reverse() attrs.reverse() for i, t in enumerate(tracks): t['trackIndex'] = i
[docs] def sort_tracks_by_name(self) -> None: """Sort tracks alphabetically by name.""" tracks = self._data['sceneTrack']['scenes'][0]['csml']['tracks'] attrs = self._data['trackAttributes'] pairs = list(zip(tracks, attrs)) pairs.sort(key=lambda p: p[1].get('ident', '')) for i, (t, a) in enumerate(pairs): t['trackIndex'] = i tracks[:] = [p[0] for p in pairs] attrs[:] = [p[1] for p in pairs]
# ------------------------------------------------------------------ # Gap manipulation # ------------------------------------------------------------------
[docs] def insert_gap(self, position_seconds: float, gap_duration_seconds: float) -> None: """Insert a gap at a position across ALL tracks, shifting subsequent clips.""" from camtasia.timing import seconds_to_ticks gap_ticks: int = seconds_to_ticks(gap_duration_seconds) position_ticks: int = seconds_to_ticks(position_seconds) for track in self.tracks: for media_dict in track._data.get('medias', []): clip_start: int = media_dict.get('start', 0) if clip_start >= position_ticks: media_dict['start'] = clip_start + gap_ticks
[docs] def remove_gap(self, position_seconds: float, gap_duration_seconds: float) -> None: """Remove a gap at a position across ALL tracks, pulling subsequent clips back.""" from camtasia.timing import seconds_to_ticks gap_ticks: int = seconds_to_ticks(gap_duration_seconds) position_ticks: int = seconds_to_ticks(position_seconds) for track in self.tracks: for media_dict in track._data.get('medias', []): clip_start: int = media_dict.get('start', 0) if clip_start >= position_ticks: media_dict['start'] = max(position_ticks, clip_start - gap_ticks)
# ------------------------------------------------------------------ # Internals # ------------------------------------------------------------------ @property def _track_list(self) -> list[dict[str, Any]]: """The raw tracks array: ``sceneTrack.scenes[0].csml.tracks``.""" return self._data['sceneTrack']['scenes'][0]['csml']['tracks'] # type: ignore[no-any-return] @property def longest_track(self) -> Track | None: """The track with the greatest end time, or None if empty.""" all_tracks = list(self.tracks) if not all_tracks: return None return max(all_tracks, key=lambda track: track.end_time_ticks())
[docs] def normalize_all_tracks(self) -> None: """Normalize timing on all tracks so each starts at time 0.""" for track in self.tracks: track.normalize_timing()
@property def clip_density(self) -> float: """Ratio of total clip duration to timeline duration (0.0-1.0+).""" timeline_duration: float = self.total_duration_seconds() if timeline_duration == 0: return 0.0 total_clip_duration: float = sum( track.total_duration_seconds for track in self.tracks ) return total_clip_duration / timeline_duration
class _TrackAccessor: """Iterable/indexable accessor over timeline tracks.""" def __init__(self, data: dict[str, Any]) -> None: self._data = data @property def _track_list(self) -> list[dict[str, Any]]: return self._data['sceneTrack']['scenes'][0]['csml']['tracks'] # type: ignore[no-any-return] @property def _attrs(self) -> list[dict[str, Any]]: return self._data.get('trackAttributes', []) # type: ignore[no-any-return] def __len__(self) -> int: return len(self._track_list) def __iter__(self) -> Iterator[Track]: for i, track_data in enumerate(self._track_list): attrs = self._attrs[i] if i < len(self._attrs) else {} yield Track(attrs, track_data, _all_tracks=self._track_list) def __getitem__(self, track_index: int) -> Track: """Get a track by its ``trackIndex``. Args: track_index: The track index. Raises: KeyError: No track with the given index. """ for i, t in enumerate(self._track_list): if t['trackIndex'] == track_index: attrs = self._attrs[i] if i < len(self._attrs) else {} return Track(attrs, t, _all_tracks=self._track_list) raise KeyError( f"No track with index={track_index}. " f"Timeline has {len(self)} tracks (indices 0\u2013{len(self)-1})" ) def __delitem__(self, track_index: int) -> None: """Remove a track by its ``trackIndex``. Args: track_index: The track index to remove. Raises: KeyError: No track with the given index. """ tracks = self._track_list attrs = self._data.get('trackAttributes', []) for i, t in enumerate(tracks): if t['trackIndex'] == track_index: tracks.pop(i) if i < len(attrs): attrs.pop(i) for j, t2 in enumerate(tracks): t2['trackIndex'] = j return raise KeyError(f'No track with index={track_index}') def insert_track(self, index: int, name: str) -> Track: """Insert a new empty track at the given index. Updates ``trackIndex`` on all tracks after insertion. Args: index: Position to insert the track. name: Display name for the track. Returns: The newly created Track. """ record: dict[str, Any] = { 'trackIndex': index, 'medias': [], 'parameters': {}, } attrs_record: dict[str, Any] = { 'ident': name, 'audioMuted': False, 'videoHidden': False, 'magnetic': False, 'matte': 0, 'solo': False, 'metadata': {'IsLocked': 'False', 'trackHeight': '33'}, } self._track_list.insert(index, record) self._data.setdefault('trackAttributes', []).insert(index, attrs_record) # Re-number all tracks since Camtasia uses index as ID for j, t in enumerate(self._track_list): t['trackIndex'] = j return self[index] def move_track(self, from_index: int, to_index: int) -> None: """Move a track from one array position to another. Args: from_index: Current array position of the track. to_index: Desired array position. Raises: IndexError: If either index is out of range. """ tracks = self._track_list attrs = self._data.get('trackAttributes', []) n = len(tracks) if not (0 <= from_index < n) or not (0 <= to_index < n): raise IndexError( f'Track index out of range: from_index={from_index}, ' f'to_index={to_index}, num_tracks={n}' ) track = tracks.pop(from_index) attr = attrs.pop(from_index) if from_index < len(attrs) else {} tracks.insert(to_index, track) attrs.insert(to_index, attr) for j, t in enumerate(tracks): t['trackIndex'] = j def reorder_tracks(self, order: list[int]) -> None: """Reorder tracks by providing current trackIndex values in desired order. Args: order: List of current ``trackIndex`` values in the new order. Raises: ValueError: If ``order`` doesn't contain exactly all current indices. """ tracks = self._track_list attrs = self._data.get('trackAttributes', []) current_indices = {t['trackIndex'] for t in tracks} if set(order) != current_indices or len(order) != len(tracks): raise ValueError( f'order must contain exactly all current trackIndex values: ' f'{sorted(current_indices)}' ) index_to_pos = {t['trackIndex']: i for i, t in enumerate(tracks)} new_tracks = [tracks[index_to_pos[idx]] for idx in order] new_attrs = [attrs[index_to_pos[idx]] for idx in order] tracks[:] = new_tracks attrs[:] = new_attrs for j, t in enumerate(tracks): t['trackIndex'] = j class _TimelineMarkers: """Timeline markers yielding ``marker.Marker`` instances for backward compat. Delegates add/remove to ``MarkerList``. """ def __init__(self, data: dict[str, Any]) -> None: self._data = data self._inner = MarkerList(data) def __iter__(self) -> Iterator[Marker]: for m in self._inner: yield Marker(name=m.name, time=m.time) def __len__(self) -> int: return len(self._inner) def add(self, name: str, time_ticks: int) -> Marker: """Add a marker at the given time.""" self._inner.add(name, time_ticks) return Marker(name=name, time=time_ticks) def remove_at(self, time: int) -> None: """Remove all markers at the given time.""" self._inner.remove_at(time) def clear(self) -> None: """Remove all markers.""" self._inner.clear() def replace(self, markers: list[tuple[str, int]]) -> None: """Replace all markers with a new set.""" self._inner.replace(markers)