Skip to content

AI auto-editing

LLM-authored editing: build a scene catalog from one or more sources and let a local vision-language model plan a VideoEdit from it. End-to-end usage is in Let a local LLM edit for you; the agent-driven variant is the MCP server.

from videopython.ai import AutoEditor, OllamaVisionLLM

editor = AutoEditor(planner=OllamaVisionLLM(model="qwen3.6:27b"))
edit = editor.edit(["a.mp4", "b.mp4"], brief="A 15s teaser, most dynamic shot first.")
edit.run_to_file("teaser.mp4")

The catalog and the by-id plan

build_catalog projects VideoAnalysis results into an EditCatalog of candidate CatalogScenes — each with a stable id, exact bounds, caption and transcript — plus one keyframe per scene. The planner authors an EditPlan whose segments reference scenes by scene_id, and resolve_plan maps those ids back to a runnable VideoEdit. The model never authors timestamps (why).

from videopython.ai import VideoAnalyzer, build_catalog
from videopython.ai.auto_edit import EditPlan, resolve_plan

analyses = [VideoAnalyzer().analyze_path("a.mp4")]
bundle = build_catalog(analyses)              # bundle.catalog + bundle.keyframes

plan = EditPlan.model_validate({"segments": [{"scene_id": bundle.catalog.scenes[0].id}]})
edit = resolve_plan(plan, bundle.catalog)     # -> VideoEdit

Speech candidates

build_catalog(..., mode="speech", speech=SpeechCandidateConfig(...)) selects timed speech passages. mode="visual" is the default and requires speech=None. SpeechCandidateConfig requires positive min_duration and max_duration in seconds, with max_duration >= min_duration. pause_duration is positive and defaults to 0.8 seconds.

Speech mode uses these deterministic rules:

  • Each nonempty transcript segment must contain timed words. Otherwise that source contributes no candidates. Empty or absent transcriptions also contribute none.
  • Words are sorted by start, then end. Times must be finite with 0 <= start <= end; invalid times raise ValueError. Zero-duration words retain their supplied timestamp and text ownership. Their duration is not inferred.
  • A span ends after ., ?, !, …, 。, ?, or ! (with closing quotes or brackets allowed), or before a gap of at least pause_duration. A boundary cannot pass through another overlapping word. An unfinished trailing span is discarded.
  • Consecutive spans are combined until the minimum duration is met. If adding a span would exceed the maximum or cross a gap of at least pause_duration, the shorter pending group is discarded. A single span longer than the maximum is also discarded. Durations include shorter internal pauses.
  • Candidates are chronological within each source and do not overlap. Source order follows the supplied analyses. Start/end are the first word start and the latest word end, without added padding. Source duration is required. Passages extending beyond it are omitted, not clamped through a timed word; supplied word records stay unchanged. An empty catalog.scenes is a valid outcome.

Speech mode does not assign a shot caption, shot type, or face flag. It can cross visual cuts. Sentence punctuation and pauses are selection cues, not a topic or meaning model: abbreviations, recognition errors, and an excerpt that starts mid-sentence can produce poor editorial boundaries. Review the retrieved text and rendered cuts. Supplied timing precision limits audio alignment; frame timing still follows the renderer's source-frame contract.

CatalogBundle.transcripts maps every candidate ID to its full normalized transcript text, in either mode. CatalogScene.transcript remains a short excerpt (280 characters by default). Whitespace is normalized; source words and times are not changed.

IDs and migration

Visual mode keeps the existing IDs. Speech IDs include a digest of the resolved source path, transcript, and speech settings, followed by the candidate index. Repeated builds with the same inputs produce the same IDs. Sources with identical file stems in different directories have distinct speech IDs.

Rebuild the catalog and regenerate saved by-ID plans when changing mode, transcript, source path, or speech settings. Old speech IDs will fail resolution rather than select new ranges. The plan field remains scene_id. This is catalog identity, not a media-content integrity check.

SpeechCandidateConfig

Bases: BaseModel

Duration limits and minimum pause for deterministic speech candidates.

Source code in src/videopython/ai/auto_edit/models.py
class SpeechCandidateConfig(BaseModel):
    """Duration limits and minimum pause for deterministic speech candidates."""

    model_config = ConfigDict(extra="forbid", frozen=True, allow_inf_nan=False)

    min_duration: float = Field(gt=0)
    max_duration: float = Field(gt=0)
    pause_duration: float = Field(0.8, gt=0)

    @model_validator(mode="after")
    def _ordered_limits(self) -> SpeechCandidateConfig:
        if self.max_duration < self.min_duration:
            raise ValueError("max_duration must be at least min_duration")
        return self

Planner

AutoEditor

Plan a VideoEdit from source videos using a structured-vision planner.

Source code in src/videopython/ai/auto_edit/editor.py
class AutoEditor:
    """Plan a VideoEdit from source videos using a structured-vision planner."""

    def __init__(
        self,
        planner: StructuredVisionLLM,
        *,
        analyzer: VideoAnalyzer | None = None,
        max_rounds: int = 3,
        normalize_target: _NormalizeTarget = "largest",
    ) -> None:
        self.planner = planner
        self._analyzer = analyzer
        self.max_rounds = max_rounds
        self.normalize_target = normalize_target

    def edit(self, sources: Sequence[str | Path], brief: str, *, context: dict[str, Any] | None = None) -> VideoEdit:
        """Analyze ``sources`` and plan an edit for ``brief`` (runs the analyzer)."""
        analyses = [self._get_analyzer().analyze_path(source) for source in sources]
        return self.edit_from_analyses(analyses, brief, context=context)

    def edit_from_analyses(
        self, analyses: Sequence[VideoAnalysis], brief: str, *, context: dict[str, Any] | None = None
    ) -> VideoEdit:
        """Plan an edit from precomputed VideoAnalysis results (no model download)."""
        bundle = build_catalog(analyses)
        metadata = _metadata_by_source(analyses)
        run_context = _merge_context(analyses, context)
        schema = EditPlan.json_schema(strict=True)
        base_text, images = _build_prompt(brief, bundle)

        feedback: str | None = None
        for _ in range(self.max_rounds):
            text = base_text if feedback is None else f"{base_text}\n\n{feedback}"
            try:
                raw = self.planner.generate_json(system=_SYSTEM_PROMPT, text=text, images=images or None, schema=schema)
                edit = resolve_plan(EditPlan.model_validate(raw), bundle.catalog)
            except (PlannerError, ValidationError, UnknownSceneIdsError) as exc:
                feedback = _shape_feedback(exc)
                continue
            edit, _ = edit.repair(metadata, context=run_context, clamp_segment_end=True)
            edit, _ = edit.normalize_dimensions(metadata, self.normalize_target, context=run_context)
            errors = edit.check(metadata, context=run_context)
            if not errors:
                return edit
            feedback = "The previous plan had these problems:\n" + "\n".join(e.to_prompt_line() for e in errors)

        raise AutoEditError(f"No valid edit after {self.max_rounds} round(s). Last feedback:\n{feedback}")

    def _get_analyzer(self) -> VideoAnalyzer:
        if self._analyzer is None:
            from videopython.ai.video_analysis import VideoAnalyzer

            self._analyzer = VideoAnalyzer()
        return self._analyzer

edit

edit(
    sources: Sequence[str | Path],
    brief: str,
    *,
    context: dict[str, Any] | None = None,
) -> VideoEdit

Analyze sources and plan an edit for brief (runs the analyzer).

Source code in src/videopython/ai/auto_edit/editor.py
def edit(self, sources: Sequence[str | Path], brief: str, *, context: dict[str, Any] | None = None) -> VideoEdit:
    """Analyze ``sources`` and plan an edit for ``brief`` (runs the analyzer)."""
    analyses = [self._get_analyzer().analyze_path(source) for source in sources]
    return self.edit_from_analyses(analyses, brief, context=context)

edit_from_analyses

edit_from_analyses(
    analyses: Sequence[VideoAnalysis],
    brief: str,
    *,
    context: dict[str, Any] | None = None,
) -> VideoEdit

Plan an edit from precomputed VideoAnalysis results (no model download).

Source code in src/videopython/ai/auto_edit/editor.py
def edit_from_analyses(
    self, analyses: Sequence[VideoAnalysis], brief: str, *, context: dict[str, Any] | None = None
) -> VideoEdit:
    """Plan an edit from precomputed VideoAnalysis results (no model download)."""
    bundle = build_catalog(analyses)
    metadata = _metadata_by_source(analyses)
    run_context = _merge_context(analyses, context)
    schema = EditPlan.json_schema(strict=True)
    base_text, images = _build_prompt(brief, bundle)

    feedback: str | None = None
    for _ in range(self.max_rounds):
        text = base_text if feedback is None else f"{base_text}\n\n{feedback}"
        try:
            raw = self.planner.generate_json(system=_SYSTEM_PROMPT, text=text, images=images or None, schema=schema)
            edit = resolve_plan(EditPlan.model_validate(raw), bundle.catalog)
        except (PlannerError, ValidationError, UnknownSceneIdsError) as exc:
            feedback = _shape_feedback(exc)
            continue
        edit, _ = edit.repair(metadata, context=run_context, clamp_segment_end=True)
        edit, _ = edit.normalize_dimensions(metadata, self.normalize_target, context=run_context)
        errors = edit.check(metadata, context=run_context)
        if not errors:
            return edit
        feedback = "The previous plan had these problems:\n" + "\n".join(e.to_prompt_line() for e in errors)

    raise AutoEditError(f"No valid edit after {self.max_rounds} round(s). Last feedback:\n{feedback}")

OllamaVisionLLM

A StructuredVisionLLM backed by a local Ollama server.

The model must be vision-capable (it is sent keyframes) AND support Ollama's structured-output format (the EditPlan schema constrains the decode). The default qwen3.6:27b is an Apache-2.0 vision model; not every model supports schema conditioning (some builds, e.g. certain MLX ones, fail it), so confirm format works for a custom model locally. ollama pull <model> first; options are extra generation options merged over temperature=0.

Thin wrapper over the shared :class:OllamaStructuredClient: its only job is to translate :class:OllamaError into the :class:PlannerError the editor retries on.

Source code in src/videopython/ai/auto_edit/local.py
class OllamaVisionLLM:
    """A StructuredVisionLLM backed by a local Ollama server.

    The model must be vision-capable (it is sent keyframes) AND support Ollama's
    structured-output ``format`` (the EditPlan schema constrains the decode). The
    default ``qwen3.6:27b`` is an Apache-2.0 vision model; not every model supports
    schema conditioning (some builds, e.g. certain MLX ones, fail it), so confirm
    ``format`` works for a custom model locally. ``ollama pull <model>`` first;
    ``options`` are extra generation options merged over ``temperature=0``.

    Thin wrapper over the shared :class:`OllamaStructuredClient`: its only job is
    to translate :class:`OllamaError` into the :class:`PlannerError` the editor
    retries on.
    """

    def __init__(
        self,
        model: str = DEFAULT_OLLAMA_MODEL,
        *,
        host: str | None = None,
        options: dict[str, Any] | None = None,
    ) -> None:
        self._client = OllamaStructuredClient(model=model, host=host, options=options)

    def generate_json(
        self, *, system: str, text: str, images: list[np.ndarray] | None, schema: dict[str, Any]
    ) -> dict[str, Any]:
        try:
            return self._client.generate_json(system=system, text=text, schema=schema, images=images or None)
        except OllamaError as exc:
            raise PlannerError(str(exc)) from exc

StructuredVisionLLM

Bases: Protocol

Returns schema-shaped JSON from a system prompt + text + optional keyframes.

The signature mirrors :meth:videopython.ai._ollama.OllamaStructuredClient.generate_json, so any structured-generation client satisfies it structurally. Implementations raise :class:PlannerError on unusable output (the editor retries those); infra errors should propagate so they are not silently retried.

Source code in src/videopython/ai/auto_edit/backend.py
@runtime_checkable
class StructuredVisionLLM(Protocol):
    """Returns schema-shaped JSON from a system prompt + text + optional keyframes.

    The signature mirrors
    :meth:`videopython.ai._ollama.OllamaStructuredClient.generate_json`, so any
    structured-generation client satisfies it structurally. Implementations
    raise :class:`PlannerError` on unusable output (the editor retries those);
    infra errors should propagate so they are not silently retried.
    """

    def generate_json(
        self, *, system: str, text: str, images: list[np.ndarray] | None, schema: dict[str, Any]
    ) -> dict[str, Any]: ...

Catalog and plan

build_catalog

build_catalog(
    analyses: Sequence[VideoAnalysis],
    *,
    keyframes: bool = True,
    max_transcript_chars: int = DEFAULT_TRANSCRIPT_CHARS,
    mode: Literal["visual", "speech"] = "visual",
    speech: SpeechCandidateConfig | None = None,
) -> CatalogBundle

Build visual scenes or timed speech passages; speech mode requires speech settings.

Source code in src/videopython/ai/auto_edit/catalog.py
def build_catalog(
    analyses: Sequence[VideoAnalysis],
    *,
    keyframes: bool = True,
    max_transcript_chars: int = DEFAULT_TRANSCRIPT_CHARS,
    mode: Literal["visual", "speech"] = "visual",
    speech: SpeechCandidateConfig | None = None,
) -> CatalogBundle:
    """Build visual scenes or timed speech passages; speech mode requires speech settings."""
    if mode not in ("visual", "speech") or (mode == "speech") != (speech is not None):
        raise ValueError("Supply speech settings exactly when mode='speech'")
    scenes: list[CatalogScene] = []
    transcripts: dict[str, str] = {}
    used_ids: set[str] = set()

    for analysis in analyses:
        source_path = analysis.source.path
        samples = analysis.scenes.samples if analysis.scenes else []
        transcription = analysis.audio.transcription if analysis.audio else None
        stem = Path(source_path).stem if source_path else "clip"

        if speech is not None:
            if analysis.source.duration is None:
                raise ValueError("Speech candidates require the source duration")
            identity = json.dumps(
                [
                    str(Path(source_path).resolve()) if source_path else None,
                    transcription.model_dump() if transcription else None,
                    speech.model_dump(),
                ],
                sort_keys=True,
            )
            digest = hashlib.sha256(identity.encode()).hexdigest()[:24]
            for index, passage in enumerate(speech_passages(transcription, speech)):
                start, end = passage[0].start, max(word.end for word in passage)
                if end > analysis.source.duration or start >= analysis.source.duration:
                    continue
                scene_id = _unique_id(f"{stem}#speech-{digest}", index, used_ids)
                text = " ".join(" ".join(word.word.split()) for word in passage).strip()
                scenes.append(
                    CatalogScene(
                        id=scene_id,
                        source=Path(source_path) if source_path else Path(stem),
                        start=start,
                        end=end,
                        duration=end - start,
                        transcript=_shorten(text, max_transcript_chars),
                        has_speech=True,
                    )
                )
                transcripts[scene_id] = text
                if keyframes and source_path is None:
                    raise ValueError(f"Scene {scene_id!r} has no source path to extract a keyframe from.")
            continue

        for sample in samples:
            scene_id = _unique_id(stem, sample.scene_index, used_ids)
            caption, shot_type = _description(sample)
            text = _transcript_text(transcription, sample.start_second, sample.end_second)
            transcripts[scene_id] = text
            transcript = _shorten(text, max_transcript_chars)
            scenes.append(
                CatalogScene(
                    id=scene_id,
                    source=Path(source_path) if source_path else Path(stem),
                    start=sample.start_second,
                    end=sample.end_second,
                    duration=max(0.0, sample.end_second - sample.start_second),
                    shot_type=shot_type,
                    caption=caption,
                    transcript=transcript,
                    has_speech=bool(transcript),
                    has_faces=bool(sample.faces),
                )
            )
            if keyframes and source_path is None:
                raise ValueError(f"Scene {scene_id!r} has no source path to extract a keyframe from.")

    frames = extract_catalog_keyframes(scenes) if keyframes else {}
    return CatalogBundle(catalog=EditCatalog(scenes=scenes), keyframes=frames, transcripts=transcripts)

resolve_plan

resolve_plan(
    plan: EditPlan, catalog: EditCatalog
) -> VideoEdit

Map each plan segment's scene_id to its exact source/start/end.

Source code in src/videopython/ai/auto_edit/resolve.py
def resolve_plan(plan: EditPlan, catalog: EditCatalog) -> VideoEdit:
    """Map each plan segment's scene_id to its exact source/start/end."""
    by_id = catalog.by_id()
    unknown = [seg.scene_id for seg in plan.segments if seg.scene_id not in by_id]
    if unknown:
        raise UnknownSceneIdsError(unknown)
    segments = [
        SegmentConfig(
            source=by_id[seg.scene_id].source,
            start=by_id[seg.scene_id].start,
            end=by_id[seg.scene_id].end,
            operations=seg.operations,
            transition_in=seg.transition_in,
        )
        for seg in plan.segments
    ]
    return VideoEdit(segments=segments, post_operations=plan.post_operations)

EditPlan

Bases: BaseModel

The planner's output: an ordered selection of catalog scenes, referenced by id.

Source code in src/videopython/ai/auto_edit/models.py
class EditPlan(BaseModel):
    """The planner's output: an ordered selection of catalog scenes, referenced by id."""

    model_config = ConfigDict(extra="forbid")

    segments: list[PlanSegment] = Field(min_length=1, description="Ordered plan segments.")
    post_operations: list[OperationInput] = Field(
        default_factory=list, description="Operations applied once to the whole assembled program."
    )

    @classmethod
    def json_schema(cls, *, strict: bool = False) -> dict[str, Any]:
        """The by-id mirror of VideoEdit.json_schema, reusing the op union and strict rewrite."""
        op_schema = Operation.json_schema()

        segment_schema: dict[str, Any] = {
            "type": "object",
            "description": PlanSegment.__doc__,
            "properties": {
                "scene_id": field_schema(PlanSegment, "scene_id"),
                "operations": array_field_schema(PlanSegment, "operations", op_schema),
                "transition_in": optional_model_field_schema(TransitionSpec, PlanSegment, "transition_in"),
            },
            "required": ["scene_id"],
            "additionalProperties": False,
        }
        segments = field_schema(cls, "segments")
        segments["items"] = segment_schema
        schema: dict[str, Any] = {
            "$schema": "http://json-schema.org/draft-07/schema#",
            "type": "object",
            "description": cls.__doc__,
            "properties": {
                "segments": segments,
                "post_operations": array_field_schema(cls, "post_operations", op_schema),
            },
            "required": ["segments"],
            "additionalProperties": False,
        }
        if not strict:
            return schema
        op_defs = op_schema.pop("$defs", None)
        if op_defs:
            schema["$defs"] = op_defs
        return _to_strict_schema(schema)

json_schema classmethod

json_schema(*, strict: bool = False) -> dict[str, Any]

The by-id mirror of VideoEdit.json_schema, reusing the op union and strict rewrite.

Source code in src/videopython/ai/auto_edit/models.py
@classmethod
def json_schema(cls, *, strict: bool = False) -> dict[str, Any]:
    """The by-id mirror of VideoEdit.json_schema, reusing the op union and strict rewrite."""
    op_schema = Operation.json_schema()

    segment_schema: dict[str, Any] = {
        "type": "object",
        "description": PlanSegment.__doc__,
        "properties": {
            "scene_id": field_schema(PlanSegment, "scene_id"),
            "operations": array_field_schema(PlanSegment, "operations", op_schema),
            "transition_in": optional_model_field_schema(TransitionSpec, PlanSegment, "transition_in"),
        },
        "required": ["scene_id"],
        "additionalProperties": False,
    }
    segments = field_schema(cls, "segments")
    segments["items"] = segment_schema
    schema: dict[str, Any] = {
        "$schema": "http://json-schema.org/draft-07/schema#",
        "type": "object",
        "description": cls.__doc__,
        "properties": {
            "segments": segments,
            "post_operations": array_field_schema(cls, "post_operations", op_schema),
        },
        "required": ["segments"],
        "additionalProperties": False,
    }
    if not strict:
        return schema
    op_defs = op_schema.pop("$defs", None)
    if op_defs:
        schema["$defs"] = op_defs
    return _to_strict_schema(schema)

EditCatalog

Bases: BaseModel

The candidate scenes across all source videos.

Source code in src/videopython/ai/auto_edit/models.py
class EditCatalog(BaseModel):
    """The candidate scenes across all source videos."""

    scenes: list[CatalogScene]

    def by_id(self) -> dict[str, CatalogScene]:
        return {scene.id: scene for scene in self.scenes}

CatalogScene

Bases: BaseModel

A candidate scene the planner picks by id; carries the exact source bounds.

Source code in src/videopython/ai/auto_edit/models.py
class CatalogScene(BaseModel):
    """A candidate scene the planner picks by id; carries the exact source bounds."""

    id: str
    source: Path
    start: float
    end: float
    duration: float
    shot_type: str | None = None
    caption: str = ""
    transcript: str = ""
    has_speech: bool = False
    has_faces: bool = False

Errors

AutoEditError

Bases: AiError, RuntimeError

The planner could not produce a valid edit within the retry budget.

Source code in src/videopython/ai/auto_edit/editor.py
class AutoEditError(AiError, RuntimeError):
    """The planner could not produce a valid edit within the retry budget."""

PlannerError

Bases: AiError, RuntimeError

A backend produced unusable output; the editor retries (infra errors should propagate instead).

Source code in src/videopython/ai/auto_edit/backend.py
class PlannerError(AiError, RuntimeError):
    """A backend produced unusable output; the editor retries (infra errors should propagate instead)."""

UnknownSceneIdsError

Bases: AiError, ValueError

An EditPlan referenced scene ids absent from the catalog.

Source code in src/videopython/ai/auto_edit/resolve.py
class UnknownSceneIdsError(AiError, ValueError):
    """An EditPlan referenced scene ids absent from the catalog."""

    def __init__(self, ids: list[str]) -> None:
        self.ids = ids
        super().__init__(f"Plan references unknown scene ids: {sorted(set(ids))}")

Keyframe memory

Python catalogs with keyframes=True retain every selected full-resolution RGB frame. Selected-frame extraction reads directly into one output array, preserving request order and duplicate timestamps. An unreachable frame raises VideoLoadError rather than returning a shorter array. The array needs approximately N × width × height × 3 bytes for N frames, plus decoder and process overhead. At 300 frames of 1920×1080, that array alone is about 1.87 GB. Use keyframes=False for text-only selection, then request a shortlist. This analysis allocation is separate from the renderer's bounded frame buffers. MCP has its own request and cache limits.