Skip to content

LLM-first design

"LLM-friendly" is usually a wrapper: a library, plus a hand-written layer that translates between JSON and the library's real API. That layer drifts from the API it wraps, and every new feature has to be added twice.

videopython removes the layer. Every editing primitive is a Pydantic BaseModel whose fields are the JSON wire format. There is no translation step to drift, because the schema is generated from the same class the engine executes.

The schema is the API

class Resize(Operation):
    """Resize the video."""

    op: Literal["resize"] = "resize"          # discriminator + registry key
    category: ClassVar[OpCategory] = OpCategory.TRANSFORM

    width: int | None = Field(None, gt=0)
    height: int | None = Field(None, gt=0)

Field types, defaults, and value constraints surface as standard JSON Schema. op is a one-value Literal field, not a ClassVar, so it travels on the wire as the discriminator and doubles as the registry key.

Subclasses auto-register through __pydantic_init_subclass__, so importing videopython.editing populates the registry, and VideoEdit.json_schema() builds a discriminated union over everything in it. Adding an operation adds it to the LLM's vocabulary — no second place to update.

Two visibility switches

Not everything the engine can execute is something a model should emit.

llm_exposed: ClassVar[bool] — set False for operations needing a server-resolved path (image_overlay, full_image_overlay). A model asked for a watermark would have to invent a file path it has no way to know. So Operation.llm_registry() and the default json_schema() omit them, while Operation.registry() and from_dict still see everything — a stored plan that legitimately contains one keeps executing.

llm_hidden on a field — Field(json_schema_extra={"llm_hidden": True}) keeps a field on the wire (it parses, it runs) but drops it from the LLM-facing schema. The raw font_filename path on text_overlay/add_subtitles is the canonical case: its LLM-facing counterpart is the font name enum, which is a fixed set the model can actually choose from and a stored plan can round-trip on.

cls.model_json_schema() keeps hidden fields; cls.llm_json_schema() and the default Operation.json_schema() strip them.

Make the invalid unrepresentable, then repair the rest

Two mechanisms, at different points in the pipeline.

At decode time, json_schema(strict=True) emits a closed grammar — every object additionalProperties: false, every property required, the union as an anyOf of closed variants with no discriminator, $defs hoisted to the root. Optionality follows the Pydantic type. A decoder that enforces these features can prevent unknown fields and invalid enum values. Structural validators, numeric bounds, and cross-field rules still require parsing and validation.

After decode, cross-field constraints take over — timestamp < duration, segment dimension equality. No grammar can express those, so they live in check() / repair() / normalize_dimensions(). See the plan lifecycle.

The schema describes the model input. Validation checks whether that input makes sense for the selected media. Mechanical repairs handle only unambiguous changes.

Selection by id

The auto-editor exists because a vision model is good at one thing and bad at another. It judges shots well. It authors timestamps badly.

So AutoEditor never asks for a timestamp. Analysis produces a catalog of candidate scenes, each with a stable scene_id, exact bounds from scene detection, a caption, a transcript, and a keyframe. The model authors a plan referencing scenes by id and adds operations; resolve_plan maps ids back to the detected bounds.

The model's temporal imprecision therefore cannot reach the render. Precision comes from the detector, judgment from the model, and each does only what it is good at.

The same idea drives the MCP server's payload discipline: the catalog text is always complete so the agent can shortlist from captions, while keyframes — the payload that grows with footage — are downscaled and capped, fetched on demand by id.

Three ways to put a model in the loop

They differ in one thing: who owns the model.

Mode Planner Guide
Bring your own LLM Yours, in your harness Author edit plans with your own LLM
AutoEditor An Ollama vision model Let a local LLM edit for you
MCP server The connecting agent's own model Drive editing from an MCP agent

All three sit on the same registry, the same schema, and the same validate/repair loop. The auto-editor and the MCP server are thin — most of what makes them work is the primitives described above.

AI operations are registered lazily

AI operation classes register when their defining modules load. Import FaceTrackingCrop and ObjectDetectionOverlay from videopython.ai before schema generation or parsing a plan that uses them. Importing videopython.ai alone leaves its lazy exports unloaded. See the LLM guide for the import example.