Source code for camtasia.operations.speed

"""Speed changes with full timeline re-sync.

Ported from the tested camtasia_stretch.py script that successfully
rescaled a project from 1.07x audio to 1.0x on 2026-04-08.
"""

from __future__ import annotations

from fractions import Fraction
from typing import TYPE_CHECKING, Any

if TYPE_CHECKING:
    from camtasia.project import Project

from camtasia.timing import EDIT_RATE, parse_scalar, scalar_to_string


def _frac(value: int | float | str) -> Fraction:
    """Parse a tick value that may be int, float, or string fraction."""
    if isinstance(value, str) and "/" in value:
        return Fraction(value)
    return Fraction(value)


def _scale_tick(value: int | float | str, factor: Fraction) -> int | str:
    """Scale a tick value by *factor*, preserving type for fraction strings."""
    f = _frac(value) * factor
    if isinstance(value, str) and "/" in value:
        return f"{f.numerator}/{f.denominator}" if f.denominator != 1 else int(f)
    return int(round(float(f)))


def _scale_clip_timing(clip: dict[str, Any], factor: Fraction) -> None:
    """Scale start and duration of a clip on the timeline."""
    clip["start"] = _scale_tick(clip["start"], factor)
    clip["duration"] = _scale_tick(clip["duration"], factor)


def _adjust_scalar(clip: dict[str, Any], factor: Fraction) -> None:
    """For speed-changed clips, adjust scalar: new = old / factor."""
    old = parse_scalar(clip.get("scalar", 1))
    new = old / factor
    clip["scalar"] = scalar_to_string(new)


def _has_speed_change(clip: dict[str, Any]) -> bool:
    """Check if a clip has clipSpeedAttribute set."""
    return (
        clip.get("metadata", {})
        .get("clipSpeedAttribute", {})
        .get("value") is True
    )


def _process_clip(clip: dict[str, Any], factor: Fraction) -> None:
    """Recursively scale a clip and its nested structures."""
    ctype = clip.get("_type", "")
    _scale_clip_timing(clip, factor)

    if _has_speed_change(clip):
        _adjust_scalar(clip, factor)

    if ctype == "StitchedMedia":
        clip["mediaStart"] = _scale_tick(clip.get("mediaStart", 0), factor)
        clip["mediaDuration"] = _scale_tick(clip.get("mediaDuration", 0), factor)
        for inner in clip.get("medias", []):
            inner["start"] = _scale_tick(inner["start"], factor)
            inner["duration"] = _scale_tick(inner["duration"], factor)
            inner["mediaStart"] = _scale_tick(inner.get("mediaStart", 0), factor)
            inner["mediaDuration"] = _scale_tick(inner.get("mediaDuration", 0), factor)

    elif ctype == "Group":
        if "mediaDuration" in clip:
            clip["mediaDuration"] = _scale_tick(clip["mediaDuration"], factor)
        for track in clip.get("tracks", []):
            for inner in track.get("medias", []):
                _process_clip(inner, factor)

    elif ctype == "UnifiedMedia":
        for child_key in ("video", "audio"):
            child = clip.get(child_key)
            if child:
                _process_clip(child, factor)


[docs] def rescale_project(project_data: dict[str, Any], factor: Fraction) -> None: """Scale all timing values in a project by *factor*. Mutates *project_data* in-place. For clips with existing speed changes, adjusts their scalar so source-media alignment is preserved. Args: project_data: The raw project JSON dict. factor: Multiplicative factor for all tick values. Values > 1 stretch (slow down), < 1 compress (speed up). """ scene = project_data["timeline"]["sceneTrack"]["scenes"][0]["csml"] # Scale all tracks for track in scene["tracks"]: for clip in track.get("medias", []): _process_clip(clip, factor) for tr in track.get("transitions", []): tr["duration"] = int(round(float(Fraction(tr["duration"]) * factor))) # Scale timeline markers toc = project_data["timeline"].get("parameters", {}).get("toc", {}) for kf in toc.get("keyframes", []): kf["time"] = int(round(float(Fraction(kf["time"]) * factor))) if "endTime" in kf: kf["endTime"] = int(round(float(Fraction(kf["endTime"]) * factor)))
[docs] def set_audio_speed( project_data: dict[str, Any], target_speed: float = 1.0, ) -> Fraction: """Rescale the project so audio clips play at *target_speed*. Finds audio clips with a non-unity scalar, calculates the stretch factor needed, and calls :func:`rescale_project`. Args: project_data: The raw project JSON dict. target_speed: Desired audio playback speed (1.0 = normal). Returns: The stretch factor that was applied. Raises: ValueError: No speed-changed audio clips found. """ target = Fraction(target_speed).limit_denominator(10_000) scene = project_data["timeline"]["sceneTrack"]["scenes"][0]["csml"] # Find the first audio clip with a speed change for track in scene["tracks"]: for clip in track.get("medias", []): if clip.get("_type") == "AMFile" and _has_speed_change(clip): current = parse_scalar(clip["scalar"]) # scalar < 1 means audio was sped up; we need to stretch # factor = current_scalar / target_scalar target_scalar = Fraction(1) / target factor = target_scalar / current # Reset this clip directly: scalar=1, duration=mediaDuration clip["scalar"] = scalar_to_string(Fraction(1) / target) if target == 1: clip["scalar"] = 1 clip["duration"] = clip["mediaDuration"] clip["metadata"]["clipSpeedAttribute"]["value"] = False # Rescale everything else rescale_project(project_data, factor) return factor raise ValueError("No speed-changed audio clips found")
[docs] def rescale(project: 'Project', factor: float | Fraction) -> None: """Scale all timing in a project by factor. This is a convenience wrapper around rescale_project() that accepts a Project object. """ rescale_project(project._data, Fraction(factor).limit_denominator(100000))
[docs] def normalize_audio_speed(project: 'Project', target_speed: float = 1.0) -> Fraction: """Rescale project so audio plays at target_speed. Returns the original audio scalar. """ return set_audio_speed(project._data, target_speed)