"""Base clip class wrapping the underlying JSON dict."""
from __future__ import annotations
from fractions import Fraction
from collections.abc import Callable
from typing import Any, TYPE_CHECKING
import sys
if sys.version_info >= (3, 11): # pragma: no cover
from typing import Self
else: # pragma: no cover
from typing_extensions import Self
from camtasia.effects.base import Effect, effect_from_dict
if TYPE_CHECKING:
from camtasia.timeline.track import Track
from camtasia.effects.visual import Glow
from camtasia.timing import seconds_to_ticks
from camtasia.types import BlendMode, ClipSummary, ClipType, EffectName, _ClipData
EDIT_RATE = 705_600_000
"""Ticks per second. Divisible by 30fps, 60fps, 44100Hz, 48000Hz."""
[docs]
class BaseClip:
"""Base class for all timeline clip types.
Wraps a reference to the underlying JSON dict. Mutations go directly
to the dict so ``project.save()`` always writes the current state.
Args:
data: The raw clip dict from the project JSON.
"""
def __init__(self, data: dict[str, Any]) -> None:
self._data: _ClipData = data # type: ignore[assignment]
# ------------------------------------------------------------------
# Core properties (read/write unless noted)
# ------------------------------------------------------------------
@property
def id(self) -> int:
"""Unique clip ID."""
return int(self._data['id'])
@property
def clip_type(self) -> str:
"""The ``_type`` string (e.g. ``'AMFile'``, ``'VMFile'``)."""
return self._data['_type']
@property
def is_audio(self) -> bool:
"""Whether this clip is an audio clip."""
return self.clip_type == ClipType.AUDIO
@property
def is_video(self) -> bool:
"""Whether this clip is a video clip."""
return self.clip_type in (ClipType.VIDEO, ClipType.SCREEN_VIDEO)
@property
def is_visible(self) -> bool:
"""Whether this clip is a visual clip (not audio-only)."""
return not self.is_audio
@property
def is_image(self) -> bool:
"""Whether this clip is an image clip."""
return self.clip_type == ClipType.IMAGE
@property
def is_group(self) -> bool:
"""Whether this clip is a group clip."""
return self.clip_type == ClipType.GROUP
@property
def is_callout(self) -> bool:
"""Whether this clip is a callout clip."""
return self.clip_type == ClipType.CALLOUT
@property
def is_stitched(self) -> bool:
"""Whether this clip is a stitched media clip."""
return self.clip_type == ClipType.STITCHED_MEDIA
@property
def is_placeholder(self) -> bool:
"""Whether this clip is a placeholder clip."""
return self.clip_type == ClipType.PLACEHOLDER
@property
def start(self) -> int:
"""Timeline position in ticks."""
return int(self._data['start'])
@start.setter
def start(self, value: int) -> None:
"""Set the start."""
self._data['start'] = value
@property
def duration(self) -> int:
"""Playback duration in ticks."""
return int(self._data['duration'])
@duration.setter
def duration(self, value: int) -> None:
"""Set the duration."""
self._data['duration'] = value
@property
def end_seconds(self) -> float:
"""End time in seconds (start + duration)."""
from camtasia.timing import ticks_to_seconds
return ticks_to_seconds(self.start + self.duration)
@property
def time_range(self) -> tuple[float, float]:
"""(start_seconds, end_seconds) tuple."""
return (self.start_seconds, self.end_seconds)
@property
def time_range_formatted(self) -> str:
"""Time range as 'MM:SS - MM:SS' string."""
def _fmt(seconds: float) -> str:
minutes: int = int(seconds // 60)
remaining: int = int(seconds % 60)
return f'{minutes}:{remaining:02d}'
return f'{_fmt(self.start_seconds)} - {_fmt(self.end_seconds)}'
@property
def gain(self) -> float:
"""Audio gain (0.0 = muted, 1.0 = full volume)."""
return float(self._data.get('attributes', {}).get('gain', 1.0))
@gain.setter
def gain(self, value: float) -> None:
"""Set the gain."""
self._data.setdefault('attributes', {})['gain'] = value
[docs]
def is_at(self, time_seconds: float) -> bool:
"""Whether this clip spans the given time point."""
from camtasia.timing import seconds_to_ticks
t = seconds_to_ticks(time_seconds)
return self.start <= t < self.start + self.duration
[docs]
def is_between(self, range_start_seconds: float, range_end_seconds: float) -> bool:
"""Whether this clip falls entirely within the given time range."""
return self.start_seconds >= range_start_seconds and self.end_seconds <= range_end_seconds
[docs]
def intersects(self, range_start_seconds: float, range_end_seconds: float) -> bool:
"""Whether this clip overlaps with the given time range at all."""
return self.start_seconds < range_end_seconds and self.end_seconds > range_start_seconds
@property
def is_muted(self) -> bool:
"""Whether this clip's audio is muted (gain == 0)."""
return self.gain == 0.0
[docs]
def mute(self) -> Self:
"""Mute this clip's audio by setting gain to 0.
Returns:
``self`` for chaining.
"""
self.gain = 0.0
return self
@property
def media_start(self) -> int | float | str | Fraction: # type: ignore[override]
"""Offset into source media in ticks.
May be a rational fraction string for speed-changed clips.
"""
raw = self._data['mediaStart'] # type: ignore[typeddict-item]
if isinstance(raw, str):
return Fraction(raw)
return raw
@media_start.setter
def media_start(self, value: int | Fraction) -> None:
"""Set the media start."""
self._data['mediaStart'] = value # type: ignore[typeddict-item]
@property
def media_duration(self) -> int | float | str | Fraction: # type: ignore[override]
"""Source media window in ticks."""
raw = self._data['mediaDuration'] # type: ignore[typeddict-item]
if isinstance(raw, str):
return Fraction(raw)
return raw
@media_duration.setter
def media_duration(self, value: int | Fraction) -> None:
"""Set the media duration."""
self._data['mediaDuration'] = value # type: ignore[typeddict-item]
@property
def scalar(self) -> Fraction:
"""Speed scalar as a ``Fraction``.
Parses from int, float, or string like ``'51/101'``.
"""
raw = self._data.get('scalar', 1)
if isinstance(raw, str):
return Fraction(raw)
return Fraction(raw)
@scalar.setter
def scalar(self, value: Fraction | int | float | str) -> None:
"""Set the scalar."""
self._data['scalar'] = str(Fraction(value))
[docs]
def set_speed(self, speed: float) -> Self:
"""Set playback speed multiplier.
Args:
speed: Speed multiplier (1.0 = normal, 2.0 = double speed, 0.5 = half speed).
"""
if speed <= 0:
raise ValueError(f'speed must be > 0, got {speed}')
scalar_fraction = Fraction(1) / Fraction(speed).limit_denominator(100000)
self._data['scalar'] = 1 if speed == 1.0 else str(scalar_fraction)
self._data['mediaDuration'] = int(self.duration / float(scalar_fraction)) # type: ignore[typeddict-item]
self._data.setdefault('metadata', {})['clipSpeedAttribute'] = {'type': 'bool', 'value': True}
return self
@property
def speed(self) -> float:
"""Current playback speed multiplier."""
from camtasia.timing import scalar_to_speed
return float(scalar_to_speed(self.scalar))
@property
def has_effects(self) -> bool:
"""Whether this clip has any effects applied."""
return bool(self._data.get('effects'))
@property
def effect_count(self) -> int:
"""Number of effects on this clip."""
return len(self._data.get('effects', []))
@property
def keyframe_count(self) -> int:
"""Total number of keyframes across all parameters."""
total_keyframes: int = 0
for parameter_value in self._data.get('parameters', {}).values():
if isinstance(parameter_value, dict) and 'keyframes' in parameter_value:
total_keyframes += len(parameter_value['keyframes'])
return total_keyframes
@property
def is_at_origin(self) -> bool:
"""Whether this clip starts at time 0."""
return self.start == 0
@property
def effect_names(self) -> list[str]:
"""Names of all effects on this clip."""
return [e.get('effectName', '?') for e in self._data.get('effects', [])]
@property
def effects(self) -> list[dict[str, Any]]:
"""Raw effect dicts (will be wrapped by the effects module later)."""
return self._data.get('effects', [])
[docs]
def remove_effect_by_name(self, effect_name: str | EffectName) -> int:
"""Remove all effects with the given name. Returns count removed."""
effects = self._data.get('effects', [])
original = len(effects)
self._data['effects'] = [e for e in effects if e.get('effectName') != effect_name]
return original - len(self._data['effects'])
[docs]
def is_effect_applied(self, effect_name: str | EffectName) -> bool:
"""Check if a specific effect is applied to this clip.
Args:
effect_name: The effect name string or :class:`EffectName` enum member.
Returns:
True if at least one effect with the given name exists on this clip.
"""
return any(
effect_dict.get('effectName') == effect_name
for effect_dict in self._data.get('effects', [])
)
@property
def parameters(self) -> dict[str, Any]:
"""Clip parameters dict."""
return self._data.get('parameters', {})
@property
def opacity(self) -> float:
"""Clip opacity (0.0–1.0)."""
params = self._data.get('parameters', {})
val = params.get('opacity', 1.0)
return float(val['defaultValue'] if isinstance(val, dict) else val)
@opacity.setter
def opacity(self, value: float) -> None:
"""Set the opacity."""
if not 0.0 <= value <= 1.0:
raise ValueError(f'opacity must be 0.0-1.0, got {value}')
self._data.setdefault('parameters', {})['opacity'] = value
@property
def volume(self) -> float:
"""Audio volume (>= 0.0)."""
params = self._data.get('parameters', {})
val = params.get('volume', 1.0)
return float(val['defaultValue'] if isinstance(val, dict) else val)
@volume.setter
def volume(self, value: float) -> None:
"""Set the volume."""
if value < 0.0:
raise ValueError(f'volume must be >= 0.0, got {value}')
self._data.setdefault('parameters', {})['volume'] = value
@property
def is_silent(self) -> bool:
"""Whether this clip has zero volume (gain == 0 or volume == 0)."""
return self.gain == 0.0 or self.volume == 0.0
@property
def metadata(self) -> dict[str, Any]:
"""Clip metadata dict."""
return self._data.get('metadata', {})
@property
def animation_tracks(self) -> dict[str, Any]:
"""Animation tracks dict."""
return self._data.get('animationTracks', {})
@property
def visual_animations(self) -> list[dict[str, Any]]:
"""Visual animation array from animationTracks."""
return self.animation_tracks.get('visual', []) # type: ignore[no-any-return]
@property
def source_id(self) -> int | None:
"""Source bin ID (``src`` field), or ``None`` if absent."""
return self._data.get('src')
[docs]
def set_source(self, source_id: int) -> Self:
"""Change the media source reference for this clip."""
self._data['src'] = source_id
return self
@property
def source_effect(self) -> dict[str, Any] | None:
"""Source effect applied to this clip, or ``None``."""
return self._data.get('sourceEffect')
[docs]
def set_source_effect(
self,
*,
color0: tuple[int, int, int] | None = None,
color1: tuple[int, int, int] | None = None,
color2: tuple[int, int, int] | None = None,
color3: tuple[int, int, int] | None = None,
mid_point: float | tuple[float, float] = 0.5,
speed: float = 5.0,
source_file_type: str = 'tscshadervid',
) -> None:
"""Create or replace the clip's sourceEffect for shader backgrounds.
Colors are 0-255 RGB tuples. They're converted to 0.0-1.0 internally.
"""
params: dict[str, Any] = {}
for i, color in enumerate([color0, color1, color2, color3]):
if color is not None:
r, g, b = color
params[f'Color{i}-red'] = r / 255
params[f'Color{i}-green'] = g / 255
params[f'Color{i}-blue'] = b / 255
params[f'Color{i}-alpha'] = 1.0
if isinstance(mid_point, tuple):
params['MidPointX'] = mid_point[0]
params['MidPointY'] = mid_point[1]
else:
params['MidPoint'] = mid_point
params['Speed'] = speed
params['sourceFileType'] = source_file_type
self._data['sourceEffect'] = {
'effectName': 'SourceEffect',
'bypassed': False,
'category': '',
'parameters': params,
}
# ------------------------------------------------------------------
# Convenience
# ------------------------------------------------------------------
@property
def start_seconds(self) -> float:
"""Timeline position in seconds."""
return self.start / EDIT_RATE
@start_seconds.setter
def start_seconds(self, value: float) -> None:
"""Set the start_seconds."""
self.start = seconds_to_ticks(value)
@property
def duration_seconds(self) -> float:
"""Playback duration in seconds."""
return self.duration / EDIT_RATE
@duration_seconds.setter
def duration_seconds(self, value: float) -> None:
"""Set the duration_seconds."""
self.duration = seconds_to_ticks(value)
[docs]
def is_shorter_than(self, threshold_seconds: float) -> bool:
"""Whether this clip's duration is less than the given threshold."""
return self.duration_seconds < threshold_seconds
[docs]
def set_start_seconds(self, start_seconds: float) -> Self:
"""Set the clip start position in seconds.
Args:
start_seconds: New start position in seconds.
Returns:
Self for method chaining.
"""
from camtasia.timing import seconds_to_ticks
self._data['start'] = seconds_to_ticks(start_seconds)
return self
[docs]
def set_duration_seconds(self, duration_seconds: float) -> Self:
"""Set the clip duration in seconds.
Args:
duration_seconds: New duration in seconds.
Returns:
Self for method chaining.
"""
from camtasia.timing import seconds_to_ticks
self._data['duration'] = seconds_to_ticks(duration_seconds)
return self
[docs]
def set_time_range(self, start_seconds: float, duration_seconds: float) -> Self:
"""Set both start position and duration in seconds.
Returns self for chaining.
"""
self._data['start'] = seconds_to_ticks(start_seconds)
self._data['duration'] = seconds_to_ticks(duration_seconds)
return self
def __eq__(self, other: object) -> bool:
if not isinstance(other, BaseClip):
return NotImplemented
return self._data is other._data or self.id == other.id
def __hash__(self) -> int:
return hash(self.id)
def __repr__(self) -> str:
return (
f"{type(self).__name__}(id={self.id}, "
f"start={self.start_seconds:.2f}s, "
f"duration={self.duration_seconds:.2f}s)"
)
def __str__(self) -> str:
return f'{self.clip_type}(id={self.id}, {self.duration_seconds:.1f}s)'
# ------------------------------------------------------------------
# L2 convenience — time-bounded effects
# ------------------------------------------------------------------
[docs]
def copy_effects_from(self, source: BaseClip) -> Self:
"""Copy all effects from another clip.
Deep copies the source clip's effects array into this clip.
Existing effects on this clip are preserved (new effects appended).
Args:
source: Clip to copy effects from.
Returns:
self for chaining.
"""
import copy
source_effects = source._data.get('effects', [])
self._data.setdefault('effects', []).extend(copy.deepcopy(source_effects))
return self
[docs]
def duplicate_effects_to(self, target_clip: BaseClip) -> Self:
"""Copy all effects from this clip to another clip.
Convenience wrapper around :meth:`copy_effects_from` that reads
from *self* and writes to *target_clip*.
Args:
target_clip: Clip that will receive this clip's effects.
Returns:
self for chaining.
"""
target_clip.copy_effects_from(self)
return self
[docs]
def add_glow_timed(
self,
start_seconds: float,
duration_seconds: float,
radius: float = 35.0,
intensity: float = 0.35,
fade_in_seconds: float = 0.4,
fade_out_seconds: float = 1.0,
) -> Glow:
"""Add a time-bounded glow effect with fade-in/out.
Args:
start_seconds: Effect start relative to clip, in seconds.
duration_seconds: Effect duration in seconds.
radius: Glow radius.
intensity: Glow intensity.
fade_in_seconds: Fade-in duration in seconds.
fade_out_seconds: Fade-out duration in seconds.
Returns:
The created :class:`Glow` effect.
"""
record: dict[str, Any] = {
'effectName': EffectName.GLOW,
'bypassed': False,
'category': 'categoryVisualEffects',
'start': seconds_to_ticks(start_seconds),
'duration': seconds_to_ticks(duration_seconds),
'parameters': {
'radius': {'type': 'double', 'defaultValue': radius, 'interp': 'linr'},
'intensity': {'type': 'double', 'defaultValue': intensity, 'interp': 'linr'},
},
'leftEdgeMods': [{'type': 'fadeIn', 'duration': seconds_to_ticks(fade_in_seconds)}],
'rightEdgeMods': [{'type': 'fadeOut', 'duration': seconds_to_ticks(fade_out_seconds)}],
}
self._data.setdefault('effects', []).append(record)
return Glow(record)
# ------------------------------------------------------------------
# L2 — Animation helpers
# ------------------------------------------------------------------
def _ensure_visual_tracks(self) -> list[dict[str, Any]]:
"""Return the ``animationTracks.visual`` list, creating it if absent."""
tracks = self._data.setdefault('animationTracks', {})
return tracks.setdefault('visual', []) # type: ignore[no-any-return]
def _remove_opacity_tracks(self) -> None:
"""Remove all opacity entries from ``animationTracks.visual``."""
visual = self._data.get('animationTracks', {}).get('visual')
if visual is not None:
self._data['animationTracks']['visual'] = [
t for t in visual if t.get('track') != 'opacity'
]
def _add_opacity_track(self, keyframes: list[dict[str, Any]]) -> None:
"""Add opacity animation via parameters.opacity and animationTracks.visual.
Follows the Camtasia v10 pattern observed in real projects:
each keyframe specifies a TARGET value, and the corresponding
visual segment defines the animation duration to reach that target.
For fade-in + fade-out: 2 keyframes, 2 visual segments.
For fade-in only: 1 keyframe, 1 visual segment.
For fade-out only: 1 keyframe, 1 visual segment.
"""
# Build parameters.opacity keyframes — each has endTime and duration
# matching the visual segment it corresponds to.
param_kfs = []
for kf in keyframes:
param_kfs.append({
'endTime': kf['endTime'],
'time': kf['time'],
'value': kf['value'],
'duration': kf['duration'],
})
params = self._data.setdefault('parameters', {})
params['opacity'] = {
'type': 'double',
'defaultValue': 0.0,
'keyframes': param_kfs,
}
# Build animationTracks.visual — one segment per keyframe
visual = self._ensure_visual_tracks()
for kf in keyframes:
visual.append({'endTime': kf['endTime'], 'duration': kf['duration']})
def _get_existing_opacity_keyframes(self) -> list[dict[str, Any]] | None:
"""Return existing opacity keyframes in the new format, or None."""
opacity = self._data.get('parameters', {}).get('opacity')
if opacity is None:
return None
kfs = opacity.get('keyframes')
if not kfs:
return None
return [{'time': kf['time'], 'value': kf['value'],
'endTime': kf['endTime'], 'duration': kf['duration']} for kf in kfs]
def _clear_opacity(self) -> None:
"""Remove all opacity state: visual segments and parameters.opacity."""
tracks = self._data.get('animationTracks', {})
if 'visual' in tracks:
tracks['visual'] = []
params = self._data.get('parameters', {})
params.pop('opacity', None)
[docs]
def fade_in(self, duration_seconds: float) -> Self:
"""Add an opacity fade-in (0 → 1) over *duration_seconds*.
If a fade-out already exists, merges into a single unified animation.
Args:
duration_seconds: Fade duration in seconds.
Returns:
``self`` for chaining.
"""
existing = self._get_existing_opacity_keyframes()
if existing and existing[-1]['value'] == 0.0:
# Fade-out already exists — merge
fade_out_kf = existing[-1]
self._clear_opacity()
in_ticks = seconds_to_ticks(duration_seconds)
self._add_opacity_track([
{'time': 0, 'value': 1.0, 'endTime': in_ticks, 'duration': in_ticks},
fade_out_kf,
])
else: # pragma: no cover
in_ticks = seconds_to_ticks(duration_seconds)
self._add_opacity_track([
{'time': 0, 'value': 1.0, 'endTime': in_ticks, 'duration': in_ticks},
])
return self
[docs]
def fade_out(self, duration_seconds: float) -> Self:
"""Add an opacity fade-out (1 → 0) ending at the clip's end.
If a fade-in already exists, merges into a single unified animation.
Args:
duration_seconds: Fade duration in seconds.
Returns:
``self`` for chaining.
"""
ticks = seconds_to_ticks(duration_seconds)
end = int(self._data.get('duration', self.media_duration))
existing = self._get_existing_opacity_keyframes()
if existing and existing[0]['value'] == 1.0:
# Fade-in already exists — merge
fade_in_kf = existing[0]
self._clear_opacity()
self._add_opacity_track([
fade_in_kf,
{'time': end - ticks, 'value': 0.0, 'endTime': end, 'duration': ticks},
])
else: # pragma: no cover
self._add_opacity_track([
{'time': end - ticks, 'value': 0.0, 'endTime': end, 'duration': ticks},
])
return self
[docs]
def fade(
self,
fade_in_seconds: float = 0.0,
fade_out_seconds: float = 0.0,
) -> Self:
"""Apply fade-in and/or fade-out, replacing existing opacity animations.
Uses the Camtasia v10 keyframe pattern: each keyframe specifies a
target opacity value, and its duration defines the animation period.
Args:
fade_in_seconds: Fade-in duration (0 to skip).
fade_out_seconds: Fade-out duration (0 to skip).
Returns:
``self`` for chaining.
"""
self._clear_opacity()
end = int(self._data.get('duration', self.media_duration))
kfs: list[dict[str, Any]] = []
if fade_in_seconds > 0:
in_ticks = seconds_to_ticks(fade_in_seconds)
kfs.append({
'time': 0, 'value': 1.0,
'endTime': in_ticks, 'duration': in_ticks,
})
if fade_out_seconds > 0:
out_ticks = seconds_to_ticks(fade_out_seconds)
kfs.append({
'time': end - out_ticks, 'value': 0.0,
'endTime': end, 'duration': out_ticks,
})
if kfs:
self._add_opacity_track(kfs)
return self
[docs]
def set_opacity(self, opacity: float) -> Self:
"""Set a static opacity for the entire clip.
Args:
opacity: Opacity value (0.0–1.0).
Returns:
``self`` for chaining.
"""
if not 0.0 <= opacity <= 1.0:
raise ValueError(f'Opacity must be 0.0-1.0, got {opacity}')
self._clear_opacity()
end = int(self._data.get('duration', self.media_duration))
self._add_opacity_track([
{'time': 0, 'value': opacity, 'endTime': end, 'duration': end},
])
return self
[docs]
def clear_animations(self) -> Self:
"""Remove all visual animation entries from the clip.
Returns:
``self`` for chaining.
"""
self._data.setdefault('animationTracks', {})['visual'] = []
return self
# ------------------------------------------------------------------
# L2 — Effect helpers
# ------------------------------------------------------------------
[docs]
def add_effect(self, effect_data: dict[str, Any]) -> Effect:
"""Append a raw effect dict to this clip's effects list.
Args:
effect_data: A complete Camtasia effect dict.
Returns:
Wrapped :class:`Effect` instance.
"""
self._data.setdefault('effects', []).append(effect_data)
return effect_from_dict(effect_data)
[docs]
def add_drop_shadow(
self,
offset: float = 5,
blur: float = 10,
opacity: float = 0.5,
angle: float = 5.5,
color: tuple[float, float, float] = (0, 0, 0),
enabled: int = 1,
) -> Effect:
"""Add a drop-shadow effect.
Args:
offset: Shadow offset distance.
blur: Blur radius.
opacity: Shadow opacity (0.0–1.0).
angle: Shadow angle in degrees.
color: RGB colour tuple.
enabled: Whether the shadow is enabled (1=on, 0=off).
Returns:
Wrapped :class:`DropShadow` effect.
"""
return self.add_effect({
'effectName': EffectName.DROP_SHADOW,
'bypassed': False,
'category': 'categoryVisualEffects',
'parameters': {
'angle': angle,
'enabled': enabled,
'offset': offset,
'blur': blur,
'opacity': opacity,
'color-red': color[0],
'color-green': color[1],
'color-blue': color[2],
'color-alpha': 1.0,
},
})
[docs]
def add_glow(self, radius: float = 35.0, intensity: float = 0.35) -> Effect:
"""Add a glow/bloom effect.
Args:
radius: Glow radius.
intensity: Glow intensity.
Returns:
Wrapped :class:`Glow` effect.
"""
return self.add_effect({
'effectName': EffectName.GLOW,
'bypassed': False,
'category': 'categoryVisualEffects',
'parameters': {
'radius': radius,
'intensity': intensity,
},
})
[docs]
def add_round_corners(self, radius: float = 12.0) -> Effect:
"""Add a rounded-corners effect.
Args:
radius: Corner radius.
Returns:
Wrapped :class:`RoundCorners` effect.
"""
return self.add_effect({
'effectName': EffectName.ROUND_CORNERS,
'bypassed': False,
'category': 'categoryVisualEffects',
'parameters': {
'radius': radius,
'top-left': 1.0,
'top-right': 1.0,
'bottom-left': 1.0,
'bottom-right': 1.0,
},
})
[docs]
def add_color_adjustment(
self,
*,
brightness: float = 0.0,
contrast: float = 0.0,
saturation: float = 1.0,
channel: int = 0,
shadow_ramp_start: float = 0.0,
shadow_ramp_end: float = 0.0,
highlight_ramp_start: float = 1.0,
highlight_ramp_end: float = 1.0,
) -> Self:
"""Add a color adjustment effect.
Args:
brightness: -1.0 to 1.0 (0 = no change).
contrast: -1.0 to 1.0 (0 = no change).
saturation: 0.0 to 3.0 (1.0 = no change).
channel: Color channel (0 = all).
shadow_ramp_start: Shadow ramp start (0.0-1.0).
shadow_ramp_end: Shadow ramp end (0.0-1.0).
highlight_ramp_start: Highlight ramp start (0.0-1.0).
highlight_ramp_end: Highlight ramp end (0.0-1.0).
"""
self.add_effect({
'effectName': EffectName.COLOR_ADJUSTMENT,
'bypassed': False,
'category': 'categoryVisualEffects',
'parameters': {
'brightness': brightness,
'contrast': contrast,
'saturation': saturation,
'channel': channel,
'shadowRampStart': shadow_ramp_start,
'shadowRampEnd': shadow_ramp_end,
'highlightRampStart': highlight_ramp_start,
'highlightRampEnd': highlight_ramp_end,
},
})
return self
[docs]
def add_border(
self,
*,
width: float = 4.0,
color: tuple[float, float, float, float] = (1.0, 1.0, 1.0, 1.0),
corner_radius: float = 0.0,
) -> Self:
"""Add a border effect.
Args:
width: Border width in pixels.
color: RGBA color as 0.0-1.0 floats.
corner_radius: Corner rounding radius.
"""
r, g, b, a = color
self.add_effect({
'effectName': 'Border',
'bypassed': False,
'category': 'categoryVisualEffects',
'parameters': {
'width': width,
'color-red': r,
'color-green': g,
'color-blue': b,
'color-alpha': a,
'corner-radius': corner_radius,
},
})
return self
[docs]
def add_colorize(
self,
*,
color: tuple[float, float, float] = (0.5, 0.5, 0.5),
intensity: float = 0.5,
) -> Self:
"""Add a colorize/tint effect.
Args:
color: RGB color as 0.0-1.0 floats.
intensity: Effect intensity 0.0-1.0.
"""
r, g, b = color
self.add_effect({
'effectName': 'Colorize',
'bypassed': False,
'category': 'categoryVisualEffects',
'parameters': {
'color-red': r,
'color-green': g,
'color-blue': b,
'intensity': intensity,
},
})
return self
[docs]
def add_spotlight(
self,
*,
brightness: float = 0.5,
concentration: float = 0.5,
opacity: float = 0.35,
color: tuple[float, float, float, float] = (1.0, 1.0, 1.0, 0.35),
) -> Self:
"""Add a spotlight effect."""
self.add_effect({
'effectName': EffectName.SPOTLIGHT,
'bypassed': False,
'category': 'categoryVisualEffects',
'parameters': {
'color-red': color[0],
'color-green': color[1],
'color-blue': color[2],
'color-alpha': color[3],
'brightness': brightness,
'concentration': concentration,
'opacity': opacity,
'positionX': 0.0,
'positionY': 0.0,
'directionX': 0.0,
'directionY': 0.0,
},
})
return self
[docs]
def add_lut_effect(self, *, intensity: float = 1.0, preset_name: str = '') -> Self:
"""Add a color LUT (Look-Up Table) effect.
Args:
intensity: Effect intensity 0.0-1.0.
preset_name: Optional preset name for metadata.
"""
self.add_effect({
'effectName': EffectName.LUT_EFFECT,
'bypassed': False,
'category': 'categoryVisualEffects',
'parameters': {
'lutSource': '',
'lut_intensity': intensity,
'channel': 0,
'shadowRampStart': 0.0,
'shadowRampEnd': 0.0,
'highlightRampStart': 1.0,
'highlightRampEnd': 1.0,
},
'metadata': {'presetName': preset_name} if preset_name else {},
})
return self
[docs]
def add_motion_blur(self, *, intensity: float = 1.0) -> Self:
"""Add a motion blur effect."""
self.add_effect({
'effectName': EffectName.MOTION_BLUR,
'bypassed': False,
'category': 'categoryVisualEffects',
'parameters': {'intensity': intensity},
})
return self
[docs]
def add_emphasize(self, *, amount: float = 0.5) -> Self:
"""Add an audio emphasis effect.
Args:
amount: Emphasis amount 0.0-1.0.
"""
self.add_effect({
'effectName': EffectName.EMPHASIZE,
'bypassed': False,
'category': 'categoryAudioEffects',
'parameters': {
'emphasizeAmount': amount,
'emphasizeRampPosition': 0,
'emphasizeRampInTime': EDIT_RATE,
'emphasizeRampOutTime': EDIT_RATE,
},
})
return self
[docs]
def add_blend_mode(self, *, mode: int | BlendMode = BlendMode.NORMAL, intensity: float = 1.0) -> Self:
"""Add a blend mode compositing effect.
Args:
mode: Blend mode (3=multiply, 16=normal, etc.).
intensity: Effect intensity 0.0-1.0.
"""
self.add_effect({
'effectName': EffectName.BLEND_MODE,
'bypassed': False,
'category': 'categoryVisualEffects',
'parameters': {
'mode': mode,
'intensity': intensity,
'invert': 0,
'channel': 0,
'shadowRampStart': 0.0,
'shadowRampEnd': 0.0,
'highlightRampStart': 1.0,
'highlightRampEnd': 1.0,
},
})
return self
[docs]
def remove_effects(self) -> Self:
"""Remove all effects from this clip.
Returns:
``self`` for chaining.
"""
self._data['effects'] = []
return self
# ------------------------------------------------------------------
# L2 — Transform parameter helpers
# ------------------------------------------------------------------
def _get_param_value(self, key: str, default: float = 0.0) -> float:
"""Read a parameter value from either scalar or dict format."""
param = self.parameters.get(key, default)
if isinstance(param, dict):
return float(param.get('defaultValue', default))
return float(param)
def _set_param_value(self, key: str, value: float) -> None:
"""Write a parameter as compact scalar, or update defaultValue if dict exists."""
params = self._data.setdefault('parameters', {})
existing = params.get(key)
if isinstance(existing, dict):
existing['defaultValue'] = value
else: # pragma: no cover
params[key] = value
@property
def translation(self) -> tuple[float, float]:
"""``(x, y)`` translation."""
return (
self._get_param_value('translation0'),
self._get_param_value('translation1'),
)
@translation.setter
def translation(self, value: tuple[float, float]) -> None:
"""Set the translation."""
self._set_param_value('translation0', value[0])
self._set_param_value('translation1', value[1])
@property
def scale(self) -> tuple[float, float]:
"""``(x, y)`` scale factors."""
return (
self._get_param_value('scale0', 1.0),
self._get_param_value('scale1', 1.0),
)
@scale.setter
def scale(self, value: tuple[float, float]) -> None:
"""Set the scale."""
self._set_param_value('scale0', value[0])
self._set_param_value('scale1', value[1])
@property
def rotation(self) -> float:
"""Z-rotation in radians (stored as ``rotation1``)."""
return self._get_param_value('rotation1')
@rotation.setter
def rotation(self, value: float) -> None:
"""Set the rotation."""
self._set_param_value('rotation1', value)
[docs]
def move_to(self, x: float, y: float) -> Self:
"""Set the clip's canvas translation.
Returns:
``self`` for chaining.
"""
self._set_param_value('translation0', x)
self._set_param_value('translation1', y)
return self
[docs]
def scale_to(self, factor: float) -> Self:
"""Set uniform scale on both axes.
Returns:
``self`` for chaining.
"""
self._set_param_value('scale0', factor)
self._set_param_value('scale1', factor)
return self
[docs]
def scale_to_xy(self, x: float, y: float) -> Self:
"""Set non-uniform scale.
Returns:
``self`` for chaining.
"""
self._set_param_value('scale0', x)
self._set_param_value('scale1', y)
return self
[docs]
def crop(
self,
left: float = 0,
top: float = 0,
right: float = 0,
bottom: float = 0,
) -> Self:
"""Set geometry crop values (non-negative floats, pixel or fractional).
Returns:
``self`` for chaining.
"""
for name, val in [('left', left), ('top', top), ('right', right), ('bottom', bottom)]:
if val < 0:
raise ValueError(f'Crop {name} must be non-negative, got {val}')
self._set_param_value('geometryCrop0', left)
self._set_param_value('geometryCrop1', top)
self._set_param_value('geometryCrop2', right)
self._set_param_value('geometryCrop3', bottom)
return self
# ------------------------------------------------------------------
# L2 — Keyframe animation API
# ------------------------------------------------------------------
[docs]
def add_keyframe(
self,
parameter: str,
time_seconds: float,
value: float,
duration_seconds: float = 0.0,
interp: str = 'eioe',
) -> Self:
"""Add a keyframe to a clip parameter.
Returns:
``self`` for chaining.
"""
params = self._data.setdefault('parameters', {})
time_ticks = seconds_to_ticks(time_seconds)
dur_ticks = seconds_to_ticks(duration_seconds) if duration_seconds > 0 else 0
end_ticks = time_ticks + dur_ticks if dur_ticks else time_ticks
kf_entry: dict[str, Any] = {
'endTime': end_ticks,
'time': time_ticks,
'value': value,
'duration': dur_ticks,
}
if interp:
kf_entry['interp'] = interp
existing = params.get(parameter)
if isinstance(existing, dict) and 'keyframes' in existing:
existing['keyframes'].append(kf_entry)
else: # pragma: no cover
default_val = existing if isinstance(existing, (int, float)) else (
existing.get('defaultValue', 0.0) if isinstance(existing, dict) else 0.0
)
params[parameter] = {
'type': 'double',
'defaultValue': default_val,
'keyframes': [kf_entry],
}
return self
[docs]
def summary(self) -> str:
"""Human-readable clip summary."""
lines: list[str] = [
f'{self.clip_type}(id={self.id})',
f' Time: {self.time_range_formatted}',
f' Duration: {self.duration_seconds:.2f}s',
]
if self.scalar != 1:
lines.append(f' Speed: {self.speed:.2f}x')
effects = self._data.get('effects', [])
if effects:
names = [e.get('effectName', '?') for e in effects]
lines.append(f' Effects: {", ".join(names)}')
return '\n'.join(lines)
[docs]
def describe(self) -> str:
"""Human-readable clip description."""
from camtasia.timing import ticks_to_seconds
lines = [f'{type(self).__name__} (id={self.id})']
lines.append(f' Time: {ticks_to_seconds(self.start):.2f}s - {ticks_to_seconds(self.start + self.duration):.2f}s ({ticks_to_seconds(self.duration):.2f}s)')
if self.has_effects:
names = [e.get('effectName', '?') for e in self._data.get('effects', [])]
lines.append(f' Effects: {", ".join(names)}')
return '\n'.join(lines)
[docs]
def clone(self) -> BaseClip:
"""Create a deep copy of this clip with a new ID."""
import copy
cloned_data: dict[str, Any] = copy.deepcopy(dict(self._data))
# ID will be assigned when added to a track
cloned_data['id'] = -1 # sentinel, must be reassigned
from camtasia.timeline.clips import clip_from_dict
return clip_from_dict(cloned_data)
[docs]
def clear_keyframes(self, parameter: str | None = None) -> Self:
"""Remove keyframes from a parameter, or all parameters if *parameter* is ``None``.
Returns:
``self`` for chaining.
"""
params = self._data.get('parameters', {})
if parameter is not None:
p = params.get(parameter)
if isinstance(p, dict):
p.pop('keyframes', None)
else: # pragma: no cover
for p in params.values():
if isinstance(p, dict):
p.pop('keyframes', None)
return self
[docs]
def remove_all_effects(self) -> Self:
"""Remove all effects from this clip."""
self._data['effects'] = []
return self
[docs]
def set_opacity_fade(self, start_opacity: float = 1.0, end_opacity: float = 0.0, duration_seconds: float | None = None) -> Self:
"""Add an opacity fade keyframe animation."""
from camtasia.timing import seconds_to_ticks
dur = seconds_to_ticks(duration_seconds) if duration_seconds else self.duration
self._data.setdefault('parameters', {})['opacity'] = {
'type': 'double',
'defaultValue': start_opacity,
'keyframes': [
{'endTime': dur, 'time': 0, 'value': start_opacity, 'duration': dur},
{'endTime': dur, 'time': dur, 'value': end_opacity, 'duration': 0},
],
}
return self
[docs]
def set_position_keyframes(self, keyframes: list[tuple[float, float, float]]) -> Self:
"""Set position keyframes for animated movement.
Args:
keyframes: List of (time_seconds, x, y) tuples.
"""
from camtasia.timing import seconds_to_ticks
params = self._data.setdefault('parameters', {})
x_kfs = []
y_kfs = []
for t, x, y in keyframes:
ticks = seconds_to_ticks(t)
x_kfs.append({'endTime': ticks, 'time': ticks, 'value': x, 'duration': 0})
y_kfs.append({'endTime': ticks, 'time': ticks, 'value': y, 'duration': 0})
params['translation0'] = {'type': 'double', 'defaultValue': keyframes[0][1], 'keyframes': x_kfs}
params['translation1'] = {'type': 'double', 'defaultValue': keyframes[0][2], 'keyframes': y_kfs}
return self
[docs]
def set_scale_keyframes(self, keyframes: list[tuple[float, float]]) -> Self:
"""Set scale keyframes for animated scaling.
Args:
keyframes: List of (time_seconds, scale) tuples.
"""
from camtasia.timing import seconds_to_ticks
params = self._data.setdefault('parameters', {})
kfs = []
for t, s in keyframes:
ticks = seconds_to_ticks(t)
kfs.append({'endTime': ticks, 'time': ticks, 'value': s, 'duration': 0})
params['scale0'] = {'type': 'double', 'defaultValue': keyframes[0][1], 'keyframes': kfs}
params['scale1'] = {'type': 'double', 'defaultValue': keyframes[0][1], 'keyframes': list(kfs)}
return self
[docs]
def set_rotation_keyframes(self, keyframes: list[tuple[float, float]]) -> Self:
"""Set rotation keyframes for animated rotation.
Args:
keyframes: List of (time_seconds, rotation_degrees) tuples.
"""
from camtasia.timing import seconds_to_ticks
import math
params = self._data.setdefault('parameters', {})
kfs = []
for t, deg in keyframes:
ticks = seconds_to_ticks(t)
kfs.append({'endTime': ticks, 'time': ticks, 'value': math.radians(deg), 'duration': 0})
params['rotation1'] = {'type': 'double', 'defaultValue': kfs[0]['value'], 'keyframes': kfs}
return self
[docs]
def set_crop_keyframes(self, keyframes: list[tuple[float, float, float, float, float]]) -> Self:
"""Set crop keyframes for animated cropping.
Args:
keyframes: List of (time_seconds, left, top, right, bottom) tuples.
Values 0.0-1.0.
"""
from camtasia.timing import seconds_to_ticks
params = self._data.setdefault('parameters', {})
for i, name in enumerate(['geometryCrop0', 'geometryCrop1', 'geometryCrop2', 'geometryCrop3']):
kfs = []
for kf in keyframes:
ticks = seconds_to_ticks(kf[0])
kfs.append({'endTime': ticks, 'time': ticks, 'value': kf[i + 1], 'duration': 0})
params[name] = {'type': 'double', 'defaultValue': kfs[0]['value'], 'keyframes': kfs}
return self
[docs]
def set_volume_fade(self, start_volume: float = 1.0, end_volume: float = 0.0, duration_seconds: float | None = None) -> Self:
"""Add a volume fade keyframe animation."""
from camtasia.timing import seconds_to_ticks
dur = seconds_to_ticks(duration_seconds) if duration_seconds else self.duration
self._data.setdefault('parameters', {})['volume'] = {
'type': 'double',
'defaultValue': start_volume,
'keyframes': [
{'endTime': 0, 'time': 0, 'value': start_volume, 'duration': 0},
{'endTime': dur, 'time': dur, 'value': end_volume, 'duration': 0},
],
}
return self
[docs]
def animate(
self,
*,
fade_in: float = 0.0,
fade_out: float = 0.0,
scale_from: float | None = None,
scale_to: float | None = None,
move_from: tuple[float, float] | None = None,
move_to: tuple[float, float] | None = None,
) -> Self:
"""Apply common animations in one call.
Args:
fade_in: Fade-in duration in seconds (0 = no fade).
fade_out: Fade-out duration in seconds (0 = no fade).
scale_from: Starting scale (None = no scale animation).
scale_to: Ending scale (None = no scale animation).
move_from: Starting (x, y) position (None = no movement).
move_to: Ending (x, y) position (None = no movement).
"""
from camtasia.timing import ticks_to_seconds
dur = ticks_to_seconds(self.duration)
if fade_in > 0:
self.set_opacity_fade(0.0, 1.0, fade_in)
if fade_out > 0:
self.set_opacity_fade(1.0, 0.0, fade_out)
if scale_from is not None and scale_to is not None:
self.set_scale_keyframes([(0.0, scale_from), (dur, scale_to)])
if move_from is not None and move_to is not None:
self.set_position_keyframes([
(0.0, move_from[0], move_from[1]),
(dur, move_to[0], move_to[1]),
])
return self
[docs]
def to_dict(self) -> dict[str, Any]:
"""Return a summary dict of this clip's key properties."""
from camtasia.timing import ticks_to_seconds
result = {
'id': self.id,
'type': self.clip_type,
'start_seconds': ticks_to_seconds(self.start),
'duration_seconds': ticks_to_seconds(self.duration),
'end_seconds': self.end_seconds,
}
if self.source_id is not None:
result['source_id'] = self.source_id
if self.has_effects:
result['effects'] = [e.get('effectName', '?') for e in self._data.get('effects', [])]
return result
@property
def source_path(self) -> int | str:
"""Source bin ID (int) or empty string if absent (from the 'src' field)."""
return self._data.get('src', '')
@property
def media_start_seconds(self) -> float:
"""Media start offset in seconds."""
from camtasia.timing import ticks_to_seconds
return float(ticks_to_seconds(int(Fraction(str(self.media_start)))))
[docs]
def overlaps_with(self, other_clip: BaseClip) -> bool:
"""Check if this clip's time range overlaps with another clip."""
self_end: int = self.start + self.duration
other_end: int = other_clip.start + other_clip.duration
return self.start < other_end and other_clip.start < self_end
[docs]
def distance_to(self, other_clip: BaseClip) -> float:
"""Gap in seconds between this clip and another (negative if overlapping)."""
from camtasia.timing import ticks_to_seconds
self_end: int = self.start + self.duration
other_start: int = other_clip.start
gap_ticks: int = other_start - self_end
return float(ticks_to_seconds(gap_ticks))
@property
def has_keyframes(self) -> bool:
"""Whether any parameter has keyframe animation."""
for parameter_value in self._data.get('parameters', {}).values():
if isinstance(parameter_value, dict) and 'keyframes' in parameter_value:
return True
return False
[docs]
def clear_all_keyframes(self) -> Self:
"""Remove keyframes from ALL parameters, keeping default values."""
parameters: dict[str, Any] = self._data.get('parameters', {})
for parameter_name, parameter_value in parameters.items():
if isinstance(parameter_value, dict) and 'keyframes' in parameter_value:
parameters[parameter_name] = parameter_value.get('defaultValue', 0)
return self
[docs]
def copy_timing_from(self, source_clip: BaseClip) -> Self:
"""Copy start time and duration from another clip."""
self._data['start'] = source_clip.start
self._data['duration'] = source_clip.duration
return self
[docs]
def matches_type(self, clip_type: str | ClipType) -> bool:
"""Check if this clip matches the given type."""
return self.clip_type == clip_type
[docs]
def matches_any_type(self, *clip_types: str | ClipType) -> bool:
"""Check if this clip matches any of the given types."""
return any(self.matches_type(ct) for ct in clip_types)
[docs]
def snap_to_seconds(self, target_start_seconds: float) -> Self:
"""Move this clip to start at the given time in seconds."""
from camtasia.timing import seconds_to_ticks
self._data['start'] = seconds_to_ticks(target_start_seconds)
return self
[docs]
def is_longer_than(self, threshold_seconds: float) -> bool:
"""Whether this clip's duration exceeds the given threshold."""
return self.duration_seconds > threshold_seconds
[docs]
def apply_if(self, predicate: Callable[[BaseClip], bool], operation: Callable[[BaseClip], Any]) -> Self:
"""Apply an operation only if the predicate is true for this clip."""
if predicate(self):
operation(self)
return self
[docs]
def copy_to_track(self, target_track: Track) -> BaseClip:
"""Copy this clip to another track, preserving timing and effects.
Creates a deep copy of the clip data, assigns a new ID from the
target track, and appends it to the target track's media list.
Args:
target_track: The track to copy this clip into.
Returns:
The newly created clip on the target track.
"""
import copy
cloned_data: dict[str, Any] = copy.deepcopy(dict(self._data))
cloned_data['id'] = target_track._next_clip_id()
target_track._data.setdefault('medias', []).append(cloned_data)
from camtasia.timeline.clips import clip_from_dict
return clip_from_dict(cloned_data)