From 6153654e48120675f0d15e70311078978d79c697 Mon Sep 17 00:00:00 2001 From: Tao Wen Date: Sun, 9 Aug 2026 21:49:22 +0800 Subject: [PATCH] feat(analysis): add shot content understanding - Generate one editing-oriented semantic JSON with required media, shots, representative times, and scene descriptions. - Add FFmpeg shot detection plus OpenAI and reviewed JSON description providers. - Make technical metadata, embedded tags, checksum, and raw probe data opt-in through --include. - Document the workflow and cover it with schema, CLI, provider, and pipeline tests. --- CHANGELOG.md | 4 + README.md | 40 +++- ROADMAP.md | 17 +- docs/video-analysis.md | 92 ++++++++ pyproject.toml | 5 + semanticvideo.schema.json | 44 ++++ src/semanticvideo/analysis/__init__.py | 20 ++ src/semanticvideo/analysis/pipeline.py | 257 +++++++++++++++++++++++ src/semanticvideo/analysis/shots.py | 142 +++++++++++++ src/semanticvideo/analysis/types.py | 43 ++++ src/semanticvideo/cli/main.py | 126 +++++++++++ src/semanticvideo/errors.py | 25 +++ src/semanticvideo/providers/__init__.py | 6 + src/semanticvideo/providers/json_file.py | 53 +++++ src/semanticvideo/providers/openai.py | 131 ++++++++++++ src/semanticvideo/schema/annotation.py | 5 + tests/test_analysis.py | 148 +++++++++++++ tests/test_cli.py | 79 ++++++- tests/test_providers.py | 108 ++++++++++ tests/test_shots.py | 91 ++++++++ uv.lock | 196 ++++++++++++++++- 21 files changed, 1618 insertions(+), 14 deletions(-) create mode 100644 docs/video-analysis.md create mode 100644 src/semanticvideo/analysis/__init__.py create mode 100644 src/semanticvideo/analysis/pipeline.py create mode 100644 src/semanticvideo/analysis/shots.py create mode 100644 src/semanticvideo/analysis/types.py create mode 100644 src/semanticvideo/providers/__init__.py create mode 100644 src/semanticvideo/providers/json_file.py create mode 100644 src/semanticvideo/providers/openai.py create mode 100644 tests/test_analysis.py create mode 100644 tests/test_providers.py create mode 100644 tests/test_shots.py diff --git a/CHANGELOG.md b/CHANGELOG.md index bbe177c..d25a019 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,3 +14,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/). - Example Japan trip semantic manifest. - Deterministic `ffprobe` media inspection and `semanticvideo inspect` CLI. - Fixture-driven parser coverage and a synthetic video integration test. +- Single-file `semanticvideo analyze` pipeline with FFmpeg shot detection, + representative frames, structured shot content, and traceable provenance. +- Optional OpenAI vision and reviewed JSON description providers. +- Opt-in technical, embedded metadata, SHA-256, and raw FFprobe information. diff --git a/README.md b/README.md index 11fc811..acc1a55 100644 --- a/README.md +++ b/README.md @@ -44,7 +44,8 @@ came from. ## Current scope -Milestones 0 through 2 establish the schema foundation and technical inspection: +Milestones 0 through 3 establish the schema foundation and the first complete +video-understanding path: - Pydantic models for media, streams, exact time, segments, annotations, entities, evidence, and provenance @@ -53,10 +54,14 @@ Milestones 0 through 2 establish the schema foundation and technical inspection: - an example `.semantic.json` manifest - deterministic `ffprobe` inspection for real video files - a scriptable `semanticvideo inspect` command with JSON output +- FFmpeg scene-change detection and representative-frame extraction +- provider-neutral shot descriptions with an OpenAI adapter and reviewed JSON import +- one editing-oriented `.semantic.json` containing media, shots, descriptions, + provenance, and analysis parameters - tests, linting, typing, CI, documentation, and architectural decisions -Shot detection, semantic AI providers, search, EditPlan, OpenTimelineIO, and -FFmpeg rendering are intentionally scheduled for later milestones. +Search, EditPlan, OpenTimelineIO, and FFmpeg editing/rendering are scheduled for +later milestones. ## Quick start @@ -82,6 +87,33 @@ The command reports source identity, exact duration, container, bitrate, video/a subtitle streams, codecs, dimensions, frame rate, time base, rotation, color metadata, audio layout, language, timestamps, and filesystem facts as JSON. +Generate the required editing information in one file: + +```bash +uv sync --extra openai +set OPENAI_API_KEY=your_key +uv run semanticvideo analyze GX010231.MP4 --language zh-CN +``` + +The default `GX010231.semantic.json` always contains core media facts, contiguous +shot ranges, one representative time per shot, and a structured scene description. +The command fails instead of silently writing an incomplete manifest if description +generation is unavailable. + +Optional information is opt-in and remains in that same JSON: + +```bash +uv run semanticvideo analyze GX010231.MP4 --include technical --include metadata +uv run semanticvideo analyze GX010231.MP4 --include checksum --include raw +``` + +Descriptions produced elsewhere or reviewed by a person can be imported from an +object keyed by shot ID: + +```bash +uv run semanticvideo analyze GX010231.MP4 --descriptions descriptions.json +``` + Load and validate a manifest: ```python @@ -95,7 +127,7 @@ document = SemanticVideoDocument.model_validate_json( print(document.media.duration.seconds) ``` -See [media inspection](docs/media-inspection.md), +See [video analysis](docs/video-analysis.md), [media inspection](docs/media-inspection.md), [the semantic format](docs/semantic-format.md), [architecture](docs/architecture.md), and [roadmap](ROADMAP.md) for details. diff --git a/ROADMAP.md b/ROADMAP.md index 262cdf1..31de39b 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -13,18 +13,19 @@ must not compromise the provider-neutral schema foundation. - **Milestone 2 — Media inspection:** safe `ffprobe` execution, pure JSON parsing, filesystem identity, technical stream metadata, scriptable CLI, fixtures, and a synthetic video integration test. +- **Milestone 3 — Shot content analysis:** FFmpeg shot detection, representative + frames, provider-neutral structured descriptions, optional OpenAI and reviewed + JSON providers, and a complete single-file analysis command. ## Next milestones -1. **Vertical editing slice:** manually authored manifests to a minimal +1. **Vertical editing slice:** generated manifests to a minimal EditPlan and deterministic FFmpeg cut/concatenate renderer. -2. **Frame extraction and shot detection:** modular sampling and boundaries. -3. **Representative frames and signal quality:** local deterministic metrics. -4. **Timed transcription:** provider interface and one reference adapter. -5. **Structured visual semantics:** provider-neutral VLM adapter contracts. -6. **Semantic retrieval:** embeddings behind a replaceable local index. -7. **Editorial interchange:** validated EditPlan and OpenTimelineIO export. -8. **Japan trip demo:** semantic selection and a human-reviewable rough cut. +2. **Representative-frame quality:** local deterministic image/audio metrics. +3. **Timed transcription:** provider interface and one reference adapter. +4. **Semantic retrieval:** embeddings behind a replaceable local index. +5. **Editorial interchange:** validated EditPlan and OpenTimelineIO export. +6. **Japan trip demo:** semantic selection and a human-reviewable rough cut. ## Future exploration diff --git a/docs/video-analysis.md b/docs/video-analysis.md new file mode 100644 index 0000000..3544cf1 --- /dev/null +++ b/docs/video-analysis.md @@ -0,0 +1,92 @@ +# Video analysis + +`semanticvideo analyze` is the first end-to-end editing-oriented analysis path. It +uses FFprobe for source facts, FFmpeg for shot boundaries and representative JPEGs, +and a replaceable visual-description provider. The final deliverable is one +`.semantic.json`; temporary frames are removed after analysis. + +## Required output + +The command treats the following as required rather than optional: + +- source URI, exact duration, file size, container, codecs, dimensions, frame rate, + audio sample rate, and channel count +- contiguous shot ranges covering the complete source duration +- one representative timestamp inside every shot +- one structured scene annotation for every shot +- provenance, evidence timestamp, provider/model identity, and analysis parameters + +If any representative frame or description cannot be produced, the command exits +with an error and does not pretend that the analysis is complete. + +Scene annotations include a concise description plus optional environment, subjects, +actions, objects, visible text, location hint, shot type, camera movement, editorial +role, and confidence. Unknown values remain empty instead of being guessed. + +## OpenAI provider + +Install the optional dependency and configure the API key in the environment: + +```powershell +uv sync --extra openai +$env:OPENAI_API_KEY = "..." +uv run semanticvideo analyze input.mp4 --language zh-CN +``` + +The adapter submits representative images through the Responses API and requests a +strict JSON Schema result. The core pipeline only depends on the `ShotDescriber` +contract, so local or other hosted models can be added without changing the format. + +The default model is `gpt-5.6`; use `--model` to choose another compatible model. +The key is read from `OPENAI_API_KEY` and is never stored in the output manifest. + +## Reviewed JSON provider + +For offline runs, human review, or another vision system, provide a JSON object whose +keys match generated shot IDs: + +```json +{ + "shot.0001": { + "description": "A traveler walks through a railway station.", + "environment": ["indoor station"], + "subjects": ["traveler"], + "actions": ["walking"], + "objects": ["luggage"], + "shot_type": "wide shot" + } +} +``` + +Run it with: + +```powershell +uv run semanticvideo analyze input.mp4 --descriptions descriptions.json +``` + +## Optional information + +Core editing facts are always present. Repeat `--include` to add information: + +| Value | Additional content | +| --- | --- | +| `technical` | bitrate, pixel format, time bases, aspect ratio, color and VFR hints | +| `metadata` | embedded tags and filesystem/embedded timestamps | +| `checksum` | SHA-256 of the complete source file | +| `raw` | namespaced raw FFprobe response under `extensions` | + +For example: + +```powershell +uv run semanticvideo analyze input.mp4 ` + --include technical ` + --include metadata ` + --output input.semantic.json +``` + +## Shot controls + +`--scene-threshold` is FFmpeg's scene-change threshold and must be between zero and +one. Lower values detect more cuts. `--minimum-shot-duration` removes very short +detections and defaults to 0.5 seconds. Both values are persisted in `analysis_runs` +so the result can be reproduced and compared. diff --git a/pyproject.toml b/pyproject.toml index 345f4cb..a88e1be 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,6 +26,11 @@ dependencies = [ "pydantic>=2.11,<3", ] +[project.optional-dependencies] +openai = [ + "openai>=1.0", +] + [project.urls] Homepage = "https://github.com/TristinOrg/SemanticVideo" Documentation = "https://github.com/TristinOrg/SemanticVideo/tree/main/docs" diff --git a/semanticvideo.schema.json b/semanticvideo.schema.json index eb74bb0..45c5642 100644 --- a/semanticvideo.schema.json +++ b/semanticvideo.schema.json @@ -1368,6 +1368,14 @@ "additionalProperties": false, "description": "Perceptual and editorial description of a visual scene.", "properties": { + "actions": { + "default": [], + "items": { + "type": "string" + }, + "title": "Actions", + "type": "array" + }, "camera_movement": { "anyOf": [ { @@ -1405,6 +1413,26 @@ "title": "Environment", "type": "array" }, + "location_hint": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Location Hint" + }, + "objects": { + "default": [], + "items": { + "type": "string" + }, + "title": "Objects", + "type": "array" + }, "shot_type": { "anyOf": [ { @@ -1416,6 +1444,22 @@ ], "default": null, "title": "Shot Type" + }, + "subjects": { + "default": [], + "items": { + "type": "string" + }, + "title": "Subjects", + "type": "array" + }, + "visible_text": { + "default": [], + "items": { + "type": "string" + }, + "title": "Visible Text", + "type": "array" } }, "required": [ diff --git a/src/semanticvideo/analysis/__init__.py b/src/semanticvideo/analysis/__init__.py new file mode 100644 index 0000000..ef907a4 --- /dev/null +++ b/src/semanticvideo/analysis/__init__.py @@ -0,0 +1,20 @@ +"""High-level video analysis pipeline.""" + +from semanticvideo.analysis.pipeline import analyze_video +from semanticvideo.analysis.shots import ( + build_shot_ranges, + detect_shot_boundaries, + extract_frame, + representative_time, +) +from semanticvideo.analysis.types import ShotDescriber, ShotDescription + +__all__ = [ + "ShotDescriber", + "ShotDescription", + "analyze_video", + "build_shot_ranges", + "detect_shot_boundaries", + "extract_frame", + "representative_time", +] diff --git a/src/semanticvideo/analysis/pipeline.py b/src/semanticvideo/analysis/pipeline.py new file mode 100644 index 0000000..acba37e --- /dev/null +++ b/src/semanticvideo/analysis/pipeline.py @@ -0,0 +1,257 @@ +"""Compose technical inspection, shot detection, and scene descriptions.""" + +from __future__ import annotations + +import hashlib +import tempfile +from collections.abc import Collection +from datetime import UTC, datetime +from pathlib import Path +from typing import cast + +from pydantic import JsonValue + +from semanticvideo import __version__ +from semanticvideo.analysis.shots import ( + build_shot_ranges, + detect_shot_boundaries, + extract_frame, + representative_time, +) +from semanticvideo.analysis.types import ShotDescriber +from semanticvideo.media import inspect_media +from semanticvideo.media.ffprobe import run_ffprobe +from semanticvideo.schema import ( + AnalysisRun, + Annotation, + AudioStream, + Checksum, + Evidence, + GeneratorInfo, + MediaInfo, + Provenance, + ProvenanceSource, + SceneAnnotation, + SceneInfo, + Segment, + SegmentKind, + SemanticVideoDocument, + Stream, + VideoStream, +) + +INCLUDE_CHOICES = frozenset({"technical", "metadata", "checksum", "raw"}) + + +def analyze_video( + path: str | Path, + *, + describer: ShotDescriber, + ffprobe_executable: str = "ffprobe", + ffmpeg_executable: str = "ffmpeg", + timeout_seconds: float = 300, + scene_threshold: float = 0.3, + minimum_shot_duration: float = 0.5, + language: str = "en", + include: Collection[str] = (), +) -> SemanticVideoDocument: + """Generate one complete editing-oriented SemanticVideo JSON document.""" + + invalid = set(include) - INCLUDE_CHOICES + if invalid: + raise ValueError(f"unknown optional information: {', '.join(sorted(invalid))}") + + started_at = datetime.now(UTC) + media_path = Path(path) + inspected = inspect_media( + media_path, + executable=ffprobe_executable, + timeout_seconds=timeout_seconds, + ) + media = _select_media_fields(inspected, set(include)) + boundaries = detect_shot_boundaries( + media_path, + executable=ffmpeg_executable, + threshold=scene_threshold, + timeout_seconds=timeout_seconds, + ) + ranges = build_shot_ranges( + media.duration, + boundaries, + minimum_duration=minimum_shot_duration, + ) + + detector_generator = GeneratorInfo(name="semanticvideo-ffmpeg", version=__version__) + detector_provenance = Provenance( + source=ProvenanceSource.SIGNAL_ANALYSIS, + generated_at=started_at, + generator=detector_generator, + ) + description_generator = GeneratorInfo( + name=describer.name, + version=describer.version, + provider=describer.provider, + model=describer.model, + ) + segments: list[Segment] = [] + annotations: list[Annotation] = [] + + with tempfile.TemporaryDirectory(prefix="semanticvideo-") as temp_directory: + frame_directory = Path(temp_directory) + for index, time_range in enumerate(ranges, start=1): + shot_id = f"shot.{index:04d}" + annotation_id = f"annotation.scene.{index:04d}" + timestamp = representative_time(time_range) + frame = frame_directory / f"{shot_id}.jpg" + extract_frame( + media_path, + timestamp, + frame, + executable=ffmpeg_executable, + timeout_seconds=timeout_seconds, + ) + description = describer.describe(shot_id, (frame,), language=language) + evidence = Evidence( + type="representative_frame", + value={ + "timestamp": { + "value": timestamp.value, + "rate": timestamp.rate, + } + }, + ) + description_provenance = Provenance( + source=describer.source, + generated_at=started_at, + generator=description_generator, + confidence=description.confidence, + evidence=(evidence,), + ) + annotations.append( + SceneAnnotation( + id=annotation_id, + time_range=time_range, + status=describer.status, + confidence=description.confidence, + provenance=(description_provenance,), + evidence=(evidence,), + value=SceneInfo( + description=description.description, + environment=description.environment, + subjects=description.subjects, + actions=description.actions, + objects=description.objects, + visible_text=description.visible_text, + location_hint=description.location_hint, + shot_type=description.shot_type, + camera_movement=description.camera_movement, + editorial_role=description.editorial_role, + ), + ) + ) + segments.append( + Segment( + id=shot_id, + kind=SegmentKind.SHOT, + time_range=time_range, + representative_times=(timestamp,), + annotation_ids=(annotation_id,), + provenance=(detector_provenance,), + ) + ) + + extensions: dict[str, JsonValue] = {} + if "raw" in include: + raw = run_ffprobe( + media_path, + executable=ffprobe_executable, + timeout_seconds=timeout_seconds, + ) + extensions["org.semanticvideo.ffprobe"] = cast(JsonValue, raw) + + annotation_ids = tuple(annotation.id for annotation in annotations) + completed_at = datetime.now(UTC) + run = AnalysisRun( + id=f"run.{started_at.strftime('%Y%m%dT%H%M%S%fZ')}", + analyzer="semanticvideo-analyze", + analyzer_version=__version__, + started_at=started_at, + completed_at=completed_at, + capabilities=("media", "shots", "scene_descriptions"), + parameters={ + "scene_threshold": scene_threshold, + "minimum_shot_duration": minimum_shot_duration, + "language": language, + "include": sorted(include), + }, + annotation_ids=annotation_ids, + ) + return SemanticVideoDocument( + document_id=media.id.replace("asset.", "document.", 1), + generated_at=completed_at, + media=media, + segments=tuple(segments), + annotations=tuple(annotations), + analysis_runs=(run,), + extensions=extensions, + ) + + +def _select_media_fields(media: MediaInfo, include: set[str]) -> MediaInfo: + if "checksum" in include: + digest = _sha256(Path(media.uri)) + checksum = Checksum(value=digest) + else: + checksum = None + + streams: list[Stream] = [] + for stream in media.streams: + if "technical" in include: + streams.append(stream) + elif isinstance(stream, VideoStream): + streams.append( + stream.model_copy( + update={ + "bit_rate": None, + "pixel_format": None, + "time_base": None, + "sample_aspect_ratio": None, + "color_primaries": None, + "color_transfer": None, + "color_space": None, + "variable_frame_rate": None, + } + ) + ) + elif isinstance(stream, AudioStream): + streams.append( + stream.model_copy( + update={ + "bit_rate": None, + "channel_layout": None, + "time_base": None, + } + ) + ) + else: + streams.append(stream) + + keep_metadata = "metadata" in include + return media.model_copy( + update={ + "modified_at": media.modified_at if keep_metadata else None, + "created_at": media.created_at if keep_metadata else None, + "metadata": media.metadata if keep_metadata else {}, + "bit_rate": media.bit_rate if "technical" in include else None, + "checksum": checksum, + "streams": tuple(streams), + } + ) + + +def _sha256(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as source: + while chunk := source.read(1024 * 1024): + digest.update(chunk) + return digest.hexdigest() diff --git a/src/semanticvideo/analysis/shots.py b/src/semanticvideo/analysis/shots.py new file mode 100644 index 0000000..f7b35d2 --- /dev/null +++ b/src/semanticvideo/analysis/shots.py @@ -0,0 +1,142 @@ +"""Deterministic FFmpeg shot detection and representative-frame extraction.""" + +from __future__ import annotations + +import re +import subprocess +from fractions import Fraction +from itertools import pairwise +from pathlib import Path + +from semanticvideo.errors import FFmpegExecutionError, FFmpegNotFoundError +from semanticvideo.schema import RationalTime, TimeRange + +_PTS_TIME = re.compile(r"pts_time:(?P[0-9]+(?:\.[0-9]+)?)") + + +def detect_shot_boundaries( + path: Path, + *, + executable: str = "ffmpeg", + threshold: float = 0.3, + timeout_seconds: float = 300, +) -> tuple[Fraction, ...]: + """Return FFmpeg scene-change timestamps, excluding media start and end.""" + + if not 0 < threshold < 1: + raise ValueError("scene threshold must be between zero and one") + command = [ + executable, + "-nostdin", + "-hide_banner", + "-v", + "info", + "-i", + str(path), + "-an", + "-vf", + f"select='gt(scene,{threshold:g})',showinfo", + "-f", + "null", + "-", + ] + completed = _run_ffmpeg(command, "shot detection", timeout_seconds) + boundaries = { + Fraction(match.group("seconds")) + for match in _PTS_TIME.finditer(completed.stderr) + } + return tuple(sorted(boundary for boundary in boundaries if boundary > 0)) + + +def build_shot_ranges( + duration: RationalTime, + boundaries: tuple[Fraction, ...], + *, + minimum_duration: float = 0.5, +) -> tuple[TimeRange, ...]: + """Convert noisy scene changes into contiguous, minimum-length shot ranges.""" + + if minimum_duration <= 0: + raise ValueError("minimum shot duration must be greater than zero") + end = duration.fraction + minimum = Fraction(str(minimum_duration)) + cuts = [Fraction(0)] + for boundary in boundaries: + if boundary >= end or boundary - cuts[-1] < minimum: + continue + cuts.append(boundary) + if len(cuts) > 1 and end - cuts[-1] < minimum: + cuts.pop() + cuts.append(end) + return tuple( + TimeRange(start=_time(start), duration=_time(stop - start)) + for start, stop in pairwise(cuts) + ) + + +def representative_time(time_range: TimeRange) -> RationalTime: + """Choose the temporal midpoint as a stable representative frame.""" + + return _time(time_range.start_fraction + time_range.duration_fraction / 2) + + +def extract_frame( + path: Path, + timestamp: RationalTime, + output: Path, + *, + executable: str = "ffmpeg", + timeout_seconds: float = 60, +) -> None: + """Extract one JPEG frame without invoking a shell.""" + + seconds = f"{timestamp.seconds:.6f}" + command = [ + executable, + "-nostdin", + "-hide_banner", + "-v", + "error", + "-y", + "-ss", + seconds, + "-i", + str(path), + "-frames:v", + "1", + "-q:v", + "2", + str(output), + ] + _run_ffmpeg(command, "frame extraction", timeout_seconds) + if not output.is_file() or output.stat().st_size == 0: + raise FFmpegExecutionError("frame extraction", 0, "no frame was written") + + +def _run_ffmpeg( + command: list[str], operation: str, timeout_seconds: float +) -> subprocess.CompletedProcess[str]: + try: + completed = subprocess.run( + command, + capture_output=True, + check=False, + encoding="utf-8", + errors="replace", + timeout=timeout_seconds, + ) + except FileNotFoundError as error: + raise FFmpegNotFoundError( + f"ffmpeg executable was not found: {command[0]!r}" + ) from error + except subprocess.TimeoutExpired as error: + raise FFmpegExecutionError( + operation, -1, f"timed out after {timeout_seconds:g} seconds" + ) from error + if completed.returncode != 0: + raise FFmpegExecutionError(operation, completed.returncode, completed.stderr) + return completed + + +def _time(value: Fraction) -> RationalTime: + return RationalTime(value=value.numerator, rate=value.denominator) diff --git a/src/semanticvideo/analysis/types.py b/src/semanticvideo/analysis/types.py new file mode 100644 index 0000000..3d515b1 --- /dev/null +++ b/src/semanticvideo/analysis/types.py @@ -0,0 +1,43 @@ +"""Provider-neutral visual-description contracts.""" + +from __future__ import annotations + +from pathlib import Path +from typing import Protocol + +from pydantic import Field + +from semanticvideo.schema import AnnotationStatus, ProvenanceSource +from semanticvideo.schema._base import SemanticModel + + +class ShotDescription(SemanticModel): + """Editing-oriented facts visible in representative frames of one shot.""" + + description: str = Field(min_length=1) + environment: tuple[str, ...] = () + subjects: tuple[str, ...] = () + actions: tuple[str, ...] = () + objects: tuple[str, ...] = () + visible_text: tuple[str, ...] = () + location_hint: str | None = None + shot_type: str | None = None + camera_movement: str | None = None + editorial_role: str | None = None + confidence: float | None = Field(default=None, ge=0, le=1) + + +class ShotDescriber(Protocol): + """Small interface implemented by remote, local, and imported providers.""" + + name: str + version: str + provider: str | None + model: str | None + source: ProvenanceSource + status: AnnotationStatus + + def describe( + self, shot_id: str, frames: tuple[Path, ...], *, language: str + ) -> ShotDescription: + """Describe one shot from one or more representative images.""" diff --git a/src/semanticvideo/cli/main.py b/src/semanticvideo/cli/main.py index 4678bd1..87593ae 100644 --- a/src/semanticvideo/cli/main.py +++ b/src/semanticvideo/cli/main.py @@ -6,10 +6,14 @@ import sys from collections.abc import Sequence from pathlib import Path +from typing import Any from semanticvideo import __version__ +from semanticvideo.analysis import analyze_video +from semanticvideo.analysis.pipeline import INCLUDE_CHOICES from semanticvideo.errors import SemanticVideoError from semanticvideo.media import inspect_media +from semanticvideo.providers import JsonFileShotDescriber, OpenAIShotDescriber def build_parser() -> argparse.ArgumentParser: @@ -49,6 +53,85 @@ def build_parser() -> argparse.ArgumentParser: action="store_true", help="Emit compact JSON instead of indented output.", ) + + analyze_parser = commands.add_parser( + "analyze", + help="Generate one editing-oriented JSON with shots and visual descriptions.", + ) + analyze_parser.add_argument("input", type=Path, help="Input video file.") + analyze_parser.add_argument( + "-o", + "--output", + type=Path, + help="Output path (default: INPUT.semantic.json).", + ) + analyze_parser.add_argument( + "--provider", + choices=("auto", "openai", "json"), + default="auto", + help="Description provider (default: auto).", + ) + analyze_parser.add_argument( + "--descriptions", + type=Path, + help="Reviewed JSON descriptions keyed by shot ID; selects the JSON provider.", + ) + analyze_parser.add_argument( + "--model", + default="gpt-5.6", + help="OpenAI vision model (default: gpt-5.6).", + ) + analyze_parser.add_argument( + "--language", + default="en", + help="Language requested for scene descriptions (default: en).", + ) + analyze_parser.add_argument( + "--scene-threshold", + type=_unit_float, + default=0.3, + metavar="NUMBER", + help="FFmpeg scene-change sensitivity between 0 and 1 (default: 0.3).", + ) + analyze_parser.add_argument( + "--minimum-shot-duration", + type=_positive_float, + default=0.5, + metavar="SECONDS", + help="Merge shorter detected shots (default: 0.5).", + ) + analyze_parser.add_argument( + "--include", + action="append", + choices=sorted(INCLUDE_CHOICES), + default=[], + help=( + "Add optional information to the same JSON; repeat for technical, " + "metadata, checksum, or raw." + ), + ) + analyze_parser.add_argument( + "--ffmpeg", + default="ffmpeg", + help="ffmpeg executable name or path (default: ffmpeg).", + ) + analyze_parser.add_argument( + "--ffprobe", + default="ffprobe", + help="ffprobe executable name or path (default: ffprobe).", + ) + analyze_parser.add_argument( + "--timeout", + type=_positive_float, + default=300.0, + metavar="SECONDS", + help="Maximum runtime per external command (default: 300).", + ) + analyze_parser.add_argument( + "--compact", + action="store_true", + help="Emit compact JSON instead of indented output.", + ) return parser @@ -69,6 +152,27 @@ def main(argv: Sequence[str] | None = None) -> int: else: args.output.write_text(f"{rendered}\n", encoding="utf-8") return 0 + if args.command == "analyze": + describer = _description_provider(args) + document = analyze_video( + args.input, + describer=describer, + ffprobe_executable=args.ffprobe, + ffmpeg_executable=args.ffmpeg, + timeout_seconds=args.timeout, + scene_threshold=args.scene_threshold, + minimum_shot_duration=args.minimum_shot_duration, + language=args.language, + include=args.include, + ) + output = args.output or args.input.with_suffix(".semantic.json") + rendered = document.model_dump_json( + indent=None if args.compact else 2, + exclude_none=True, + ) + output.write_text(f"{rendered}\n", encoding="utf-8") + sys.stdout.write(f"Wrote {output}\n") + return 0 except (SemanticVideoError, OSError) as error: sys.stderr.write(f"error: {error}\n") return 1 @@ -82,5 +186,27 @@ def _positive_float(value: str) -> float: return parsed +def _unit_float(value: str) -> float: + parsed = float(value) + if not 0 < parsed < 1: + raise argparse.ArgumentTypeError("must be between zero and one") + return parsed + + +def _description_provider(args: Any) -> JsonFileShotDescriber | OpenAIShotDescriber: + provider = args.provider + if provider == "auto": + provider = "json" if args.descriptions is not None else "openai" + if provider == "json": + if args.descriptions is None: + raise SemanticVideoError("--provider json requires --descriptions") + return JsonFileShotDescriber(args.descriptions) + if args.descriptions is not None: + raise SemanticVideoError( + "--descriptions cannot be combined with --provider openai" + ) + return OpenAIShotDescriber(model=args.model) + + if __name__ == "__main__": raise SystemExit(main()) diff --git a/src/semanticvideo/errors.py b/src/semanticvideo/errors.py index 9103086..f61c9ae 100644 --- a/src/semanticvideo/errors.py +++ b/src/semanticvideo/errors.py @@ -29,3 +29,28 @@ def __init__(self, returncode: int, stderr: str) -> None: class FFprobeParseError(MediaInspectionError): """ffprobe output was invalid or lacked required media information.""" + + +class VideoAnalysisError(SemanticVideoError): + """Base class for shot detection and visual-description failures.""" + + +class FFmpegNotFoundError(VideoAnalysisError): + """The configured ffmpeg executable could not be started.""" + + +class FFmpegExecutionError(VideoAnalysisError): + """ffmpeg ran but returned an unsuccessful exit code.""" + + def __init__(self, operation: str, returncode: int, stderr: str) -> None: + detail = stderr.strip() or "ffmpeg returned no diagnostic output" + super().__init__( + f"ffmpeg {operation} failed with exit code {returncode}: {detail}" + ) + self.operation = operation + self.returncode = returncode + self.stderr = stderr + + +class DescriptionProviderError(VideoAnalysisError): + """A configured visual-description provider could not return valid content.""" diff --git a/src/semanticvideo/providers/__init__.py b/src/semanticvideo/providers/__init__.py new file mode 100644 index 0000000..fce7170 --- /dev/null +++ b/src/semanticvideo/providers/__init__.py @@ -0,0 +1,6 @@ +"""Built-in visual-description providers.""" + +from semanticvideo.providers.json_file import JsonFileShotDescriber +from semanticvideo.providers.openai import OpenAIShotDescriber + +__all__ = ["JsonFileShotDescriber", "OpenAIShotDescriber"] diff --git a/src/semanticvideo/providers/json_file.py b/src/semanticvideo/providers/json_file.py new file mode 100644 index 0000000..f65dd1c --- /dev/null +++ b/src/semanticvideo/providers/json_file.py @@ -0,0 +1,53 @@ +"""Import reviewed or externally generated shot descriptions from JSON.""" + +from __future__ import annotations + +import json +from pathlib import Path + +from pydantic import ValidationError + +from semanticvideo.analysis.types import ShotDescription +from semanticvideo.errors import DescriptionProviderError +from semanticvideo.schema import AnnotationStatus, ProvenanceSource + + +class JsonFileShotDescriber: + """Resolve shot descriptions from a JSON object keyed by shot ID.""" + + name: str = "semanticvideo-json-import" + version: str = "1" + provider: str | None = None + model: str | None = None + source: ProvenanceSource = ProvenanceSource.IMPORT + status: AnnotationStatus = AnnotationStatus.HUMAN_AUTHORED + + def __init__(self, path: Path) -> None: + try: + payload = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as error: + raise DescriptionProviderError( + f"cannot read description JSON {path}: {error}" + ) from error + if not isinstance(payload, dict): + raise DescriptionProviderError( + "description JSON must be an object keyed by shot ID" + ) + self._descriptions = payload + + def describe( + self, shot_id: str, frames: tuple[Path, ...], *, language: str + ) -> ShotDescription: + """Return and validate the description assigned to ``shot_id``.""" + + del frames, language + if shot_id not in self._descriptions: + raise DescriptionProviderError( + f"description JSON has no entry for {shot_id!r}" + ) + try: + return ShotDescription.model_validate(self._descriptions[shot_id]) + except ValidationError as error: + raise DescriptionProviderError( + f"invalid description for {shot_id!r}: {error}" + ) from error diff --git a/src/semanticvideo/providers/openai.py b/src/semanticvideo/providers/openai.py new file mode 100644 index 0000000..c197f00 --- /dev/null +++ b/src/semanticvideo/providers/openai.py @@ -0,0 +1,131 @@ +"""Optional OpenAI Responses API visual-description provider.""" + +from __future__ import annotations + +import base64 +import importlib +import os +from pathlib import Path +from typing import Any + +from semanticvideo.analysis.types import ShotDescription +from semanticvideo.errors import DescriptionProviderError +from semanticvideo.schema import AnnotationStatus, ProvenanceSource + + +class OpenAIShotDescriber: + """Describe representative frames with structured OpenAI vision output.""" + + name: str = "semanticvideo-openai" + version: str = "1" + provider: str | None = "openai" + source: ProvenanceSource = ProvenanceSource.REMOTE_MODEL + status: AnnotationStatus = AnnotationStatus.MACHINE_GENERATED + + def __init__( + self, + *, + model: str = "gpt-5.6", + api_key: str | None = None, + client: Any | None = None, + ) -> None: + self.model: str | None = model + if client is not None: + self._client = client + return + key = api_key or os.environ.get("OPENAI_API_KEY") + if not key: + raise DescriptionProviderError( + "OPENAI_API_KEY is required for --provider openai; alternatively " + "use --descriptions with a reviewed JSON file" + ) + try: + module = importlib.import_module("openai") + except ImportError as error: + raise DescriptionProviderError( + "OpenAI provider requires the optional dependency; run " + "`uv sync --extra openai`" + ) from error + self._client = module.OpenAI(api_key=key) + + def describe( + self, shot_id: str, frames: tuple[Path, ...], *, language: str + ) -> ShotDescription: + """Send representative JPEGs and validate one structured response.""" + + if not frames: + raise DescriptionProviderError(f"no representative frames for {shot_id}") + content: list[dict[str, str]] = [ + { + "type": "input_text", + "text": _prompt(shot_id, language), + } + ] + for frame in frames: + encoded = base64.b64encode(frame.read_bytes()).decode("ascii") + content.append( + { + "type": "input_image", + "image_url": f"data:image/jpeg;base64,{encoded}", + "detail": "low", + } + ) + try: + response = self._client.responses.create( + model=self.model, + input=[{"role": "user", "content": content}], + text={ + "format": { + "type": "json_schema", + "name": "shot_description", + "strict": True, + "schema": _strict_schema(), + } + }, + ) + output_text = getattr(response, "output_text", None) + if not isinstance(output_text, str) or not output_text.strip(): + raise DescriptionProviderError( + f"OpenAI returned no structured text for {shot_id}" + ) + return ShotDescription.model_validate_json(output_text) + except DescriptionProviderError: + raise + except Exception as error: + raise DescriptionProviderError( + f"OpenAI description failed for {shot_id}: {error}" + ) from error + + +def _prompt(shot_id: str, language: str) -> str: + return ( + f"Describe video shot {shot_id} for a professional editor. " + "Only report facts visible in the supplied representative frame(s); do not " + "identify unknown people or guess an exact location. Make description a " + f"concise sentence in language {language}. Use empty arrays or null for " + "unknown fields. Describe environment, subjects, actions, important objects, " + "visible text, shot type, camera movement when inferable, and likely editorial " + "role. Confidence is a number from 0 to 1." + ) + + +def _strict_schema() -> dict[str, Any]: + """Adapt Pydantic defaults to the API's all-properties-required subset.""" + + schema = ShotDescription.model_json_schema() + + def normalize(node: Any) -> None: + if isinstance(node, dict): + node.pop("default", None) + properties = node.get("properties") + if isinstance(properties, dict): + node["required"] = list(properties) + node["additionalProperties"] = False + for value in node.values(): + normalize(value) + elif isinstance(node, list): + for value in node: + normalize(value) + + normalize(schema) + return schema diff --git a/src/semanticvideo/schema/annotation.py b/src/semanticvideo/schema/annotation.py index bfa9a68..21b8dea 100644 --- a/src/semanticvideo/schema/annotation.py +++ b/src/semanticvideo/schema/annotation.py @@ -42,6 +42,11 @@ class SceneInfo(SemanticModel): description: str = Field(min_length=1) environment: tuple[str, ...] = () + subjects: tuple[str, ...] = () + actions: tuple[str, ...] = () + objects: tuple[str, ...] = () + visible_text: tuple[str, ...] = () + location_hint: str | None = None shot_type: str | None = None camera_movement: str | None = None editorial_role: str | None = None diff --git a/tests/test_analysis.py b/tests/test_analysis.py new file mode 100644 index 0000000..010943c --- /dev/null +++ b/tests/test_analysis.py @@ -0,0 +1,148 @@ +"""Editing-oriented single-document analysis pipeline tests.""" + +from fractions import Fraction +from pathlib import Path + +import pytest + +from semanticvideo.analysis import pipeline +from semanticvideo.analysis.types import ShotDescription +from semanticvideo.schema import ( + AnnotationStatus, + AudioStream, + MediaInfo, + ProvenanceSource, + RationalRate, + RationalTime, + SceneAnnotation, + VideoStream, +) + + +class StubDescriber: + name = "stub" + version = "1" + provider: str | None = "tests" + model: str | None = "stub-vision" + source = ProvenanceSource.LOCAL_MODEL + status = AnnotationStatus.MACHINE_GENERATED + + def describe( + self, shot_id: str, frames: tuple[Path, ...], *, language: str + ) -> ShotDescription: + assert frames[0].is_file() + return ShotDescription( + description=f"{shot_id} described in {language}", + subjects=("traveler",), + actions=("walking",), + confidence=0.8, + ) + + +def inspected_media(path: Path) -> MediaInfo: + return MediaInfo( + id="asset.test", + uri=str(path), + duration=RationalTime(value=10, rate=1), + file_size=4, + bit_rate=1000, + metadata={"title": "Trip"}, + streams=( + VideoStream( + id="stream.video.0", + index=0, + codec="h264", + bit_rate=900, + width=720, + height=1280, + frame_rate=RationalRate(numerator=30), + pixel_format="yuv420p", + ), + AudioStream( + id="stream.audio.1", + index=1, + codec="aac", + bit_rate=100, + sample_rate=48000, + channels=2, + channel_layout="stereo", + ), + ), + ) + + +def configure_pipeline(monkeypatch: pytest.MonkeyPatch, path: Path) -> None: + monkeypatch.setattr( + pipeline, "inspect_media", lambda *_args, **_kwargs: inspected_media(path) + ) + monkeypatch.setattr( + pipeline, + "detect_shot_boundaries", + lambda *_args, **_kwargs: (Fraction(4),), + ) + + def extract( + _path: Path, _timestamp: RationalTime, output: Path, **_kwargs: object + ) -> None: + output.write_bytes(b"jpeg") + + monkeypatch.setattr(pipeline, "extract_frame", extract) + + +def test_analyze_video_generates_required_editing_information( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + media_path = tmp_path / "trip.mp4" + media_path.write_bytes(b"clip") + configure_pipeline(monkeypatch, media_path) + + document = pipeline.analyze_video( + media_path, describer=StubDescriber(), language="zh-CN" + ) + + assert document.document_id == "document.test" + assert len(document.segments) == len(document.annotations) == 2 + assert document.segments[0].annotation_ids == ("annotation.scene.0001",) + first_annotation = document.annotations[0] + assert isinstance(first_annotation, SceneAnnotation) + assert first_annotation.value.description.endswith("zh-CN") + assert first_annotation.value.actions == ("walking",) + assert document.analysis_runs[0].capabilities == ( + "media", + "shots", + "scene_descriptions", + ) + assert document.media.bit_rate is None + assert document.media.metadata == {} + + +def test_optional_information_stays_in_same_document( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + media_path = tmp_path / "trip.mp4" + media_path.write_bytes(b"clip") + configure_pipeline(monkeypatch, media_path) + monkeypatch.setattr( + pipeline, "run_ffprobe", lambda *_args, **_kwargs: {"raw": True} + ) + + document = pipeline.analyze_video( + media_path, + describer=StubDescriber(), + include=("technical", "metadata", "checksum", "raw"), + ) + + assert document.media.bit_rate == 1000 + assert document.media.metadata == {"title": "Trip"} + assert document.media.checksum is not None + assert document.media.checksum.value == ( + "67905ad3cc2dd52b1f5f6a6d2814de0396618b29b4238b9af5207aeb69936e6d" + ) + assert document.extensions["org.semanticvideo.ffprobe"] == {"raw": True} + + +def test_analyze_rejects_unknown_optional_information(tmp_path: Path) -> None: + with pytest.raises(ValueError, match="unknown optional"): + pipeline.analyze_video( + tmp_path / "clip.mp4", describer=StubDescriber(), include=("faces",) + ) diff --git a/tests/test_cli.py b/tests/test_cli.py index c0d1e5d..696e8e3 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,13 +1,20 @@ """Command-line interface tests.""" import json +from datetime import UTC, datetime from pathlib import Path +from types import SimpleNamespace import pytest from semanticvideo.cli.main import build_parser, main from semanticvideo.errors import MediaNotFoundError -from semanticvideo.schema import MediaInfo, RationalTime, VideoStream +from semanticvideo.schema import ( + MediaInfo, + RationalTime, + SemanticVideoDocument, + VideoStream, +) def media_info() -> MediaInfo: @@ -83,3 +90,73 @@ def test_output_os_error_is_reported( def test_timeout_must_be_positive() -> None: with pytest.raises(SystemExit): build_parser().parse_args(["inspect", "clip.mp4", "--timeout", "0"]) + + +def test_analyze_writes_one_semantic_json( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + output = tmp_path / "trip.semantic.json" + document = SemanticVideoDocument( + document_id="document.test", + generated_at=datetime.now(UTC), + media=media_info(), + ) + provider = SimpleNamespace() + monkeypatch.setattr( + "semanticvideo.cli.main.JsonFileShotDescriber", lambda _path: provider + ) + monkeypatch.setattr( + "semanticvideo.cli.main.analyze_video", + lambda *_args, **_kwargs: document, + ) + + assert ( + main( + [ + "analyze", + "trip.mp4", + "--descriptions", + "descriptions.json", + "--include", + "technical", + "--output", + str(output), + ] + ) + == 0 + ) + assert json.loads(output.read_text(encoding="utf-8"))["document_id"] == ( + "document.test" + ) + assert capsys.readouterr().out == f"Wrote {output}\n" + + +def test_analyze_provider_arguments_are_validated( + monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + assert main(["analyze", "trip.mp4", "--provider", "json"]) == 1 + assert "requires --descriptions" in capsys.readouterr().err + + assert ( + main( + [ + "analyze", + "trip.mp4", + "--provider", + "openai", + "--descriptions", + "descriptions.json", + ] + ) + == 1 + ) + assert "cannot be combined" in capsys.readouterr().err + + monkeypatch.delenv("OPENAI_API_KEY", raising=False) + assert main(["analyze", "trip.mp4"]) == 1 + assert "OPENAI_API_KEY" in capsys.readouterr().err + + +def test_scene_threshold_must_be_a_unit_float() -> None: + with pytest.raises(SystemExit): + build_parser().parse_args(["analyze", "clip.mp4", "--scene-threshold", "1"]) diff --git a/tests/test_providers.py b/tests/test_providers.py new file mode 100644 index 0000000..1553af7 --- /dev/null +++ b/tests/test_providers.py @@ -0,0 +1,108 @@ +"""Visual description provider tests.""" + +import json +from pathlib import Path +from types import SimpleNamespace +from typing import Any + +import pytest + +from semanticvideo.errors import DescriptionProviderError +from semanticvideo.providers import JsonFileShotDescriber, OpenAIShotDescriber + + +def write_json(path: Path, value: object) -> Path: + path.write_text(json.dumps(value), encoding="utf-8") + return path + + +def test_json_provider_validates_and_resolves_shots(tmp_path: Path) -> None: + path = write_json( + tmp_path / "descriptions.json", + {"shot.0001": {"description": "Traveler enters a station"}}, + ) + provider = JsonFileShotDescriber(path) + + assert provider.describe("shot.0001", (), language="en").description.startswith( + "Traveler" + ) + with pytest.raises(DescriptionProviderError, match="no entry"): + provider.describe("shot.0002", (), language="en") + + +@pytest.mark.parametrize("content", ["not json", "[]"]) +def test_json_provider_rejects_invalid_root(tmp_path: Path, content: str) -> None: + path = tmp_path / "bad.json" + path.write_text(content, encoding="utf-8") + with pytest.raises(DescriptionProviderError): + JsonFileShotDescriber(path) + + +def test_json_provider_reports_invalid_description(tmp_path: Path) -> None: + provider = JsonFileShotDescriber( + write_json(tmp_path / "bad.json", {"shot.0001": {}}) + ) + with pytest.raises(DescriptionProviderError, match="invalid description"): + provider.describe("shot.0001", (), language="en") + + +class FakeResponses: + def __init__( + self, output: str | None = None, error: Exception | None = None + ) -> None: + self.output = output + self.error = error + self.arguments: dict[str, Any] = {} + + def create(self, **kwargs: Any) -> SimpleNamespace: + self.arguments = kwargs + if self.error is not None: + raise self.error + return SimpleNamespace(output_text=self.output) + + +def test_openai_provider_sends_image_and_parses_structured_output( + tmp_path: Path, +) -> None: + responses = FakeResponses('{"description":"A train crosses a bridge"}') + client = SimpleNamespace(responses=responses) + provider = OpenAIShotDescriber(model="vision-test", client=client) + frame = tmp_path / "frame.jpg" + frame.write_bytes(b"jpeg") + + result = provider.describe("shot.0001", (frame,), language="zh-CN") + + assert result.description == "A train crosses a bridge" + assert responses.arguments["model"] == "vision-test" + content = responses.arguments["input"][0]["content"] + assert content[1]["image_url"].startswith("data:image/jpeg;base64,") + assert "zh-CN" in content[0]["text"] + schema = responses.arguments["text"]["format"]["schema"] + assert set(schema["required"]) == set(schema["properties"]) + assert "default" not in json.dumps(schema) + + +def test_openai_provider_reports_missing_configuration( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("OPENAI_API_KEY", raising=False) + with pytest.raises(DescriptionProviderError, match="OPENAI_API_KEY"): + OpenAIShotDescriber() + + +def test_openai_provider_reports_bad_responses(tmp_path: Path) -> None: + frame = tmp_path / "frame.jpg" + frame.write_bytes(b"jpeg") + provider = OpenAIShotDescriber( + client=SimpleNamespace(responses=FakeResponses(None)) + ) + with pytest.raises(DescriptionProviderError, match="no structured text"): + provider.describe("shot.0001", (frame,), language="en") + with pytest.raises(DescriptionProviderError, match="no representative"): + provider.describe("shot.0001", (), language="en") + + failing = OpenAIShotDescriber( + client=SimpleNamespace(responses=FakeResponses(error=RuntimeError("offline"))) + ) + with pytest.raises(DescriptionProviderError, match="offline"): + failing.describe("shot.0001", (frame,), language="en") diff --git a/tests/test_shots.py b/tests/test_shots.py new file mode 100644 index 0000000..3d5da1d --- /dev/null +++ b/tests/test_shots.py @@ -0,0 +1,91 @@ +"""FFmpeg shot detection and frame extraction tests.""" + +import subprocess +from fractions import Fraction +from pathlib import Path + +import pytest + +from semanticvideo.analysis import shots +from semanticvideo.errors import FFmpegExecutionError, FFmpegNotFoundError +from semanticvideo.schema import RationalTime + + +def completed( + *, returncode: int = 0, stderr: str = "" +) -> subprocess.CompletedProcess[str]: + return subprocess.CompletedProcess([], returncode, "", stderr) + + +def test_detect_shot_boundaries_parses_unique_sorted_times( + monkeypatch: pytest.MonkeyPatch, +) -> None: + stderr = "pts_time:2.5 x\npts_time:1.25 x\npts_time:2.5 x\npts_time:0 x" + monkeypatch.setattr( + subprocess, "run", lambda *_args, **_kwargs: completed(stderr=stderr) + ) + + assert shots.detect_shot_boundaries(Path("clip.mp4")) == ( + Fraction(5, 4), + Fraction(5, 2), + ) + + +def test_shot_detection_validates_threshold() -> None: + with pytest.raises(ValueError, match="between zero and one"): + shots.detect_shot_boundaries(Path("clip.mp4"), threshold=1) + + +def test_ffmpeg_failures_are_actionable(monkeypatch: pytest.MonkeyPatch) -> None: + def missing(*_args: object, **_kwargs: object) -> None: + raise FileNotFoundError + + monkeypatch.setattr(subprocess, "run", missing) + with pytest.raises(FFmpegNotFoundError): + shots.detect_shot_boundaries(Path("clip.mp4")) + + def timeout(*_args: object, **_kwargs: object) -> None: + raise subprocess.TimeoutExpired("ffmpeg", 1) + + monkeypatch.setattr(subprocess, "run", timeout) + with pytest.raises(FFmpegExecutionError, match="timed out"): + shots.detect_shot_boundaries(Path("clip.mp4")) + + monkeypatch.setattr( + subprocess, + "run", + lambda *_args, **_kwargs: completed(returncode=2, stderr="bad filter"), + ) + with pytest.raises(FFmpegExecutionError, match="bad filter"): + shots.detect_shot_boundaries(Path("clip.mp4")) + + +def test_build_shot_ranges_merges_short_ranges() -> None: + ranges = shots.build_shot_ranges( + RationalTime(value=10, rate=1), + (Fraction(1, 10), Fraction(2), Fraction(39, 4)), + minimum_duration=0.5, + ) + + assert [(item.start_fraction, item.end_fraction) for item in ranges] == [ + (Fraction(0), Fraction(2)), + (Fraction(2), Fraction(10)), + ] + assert shots.representative_time(ranges[0]).fraction == 1 + + +def test_build_shot_ranges_rejects_non_positive_minimum() -> None: + with pytest.raises(ValueError, match="greater than zero"): + shots.build_shot_ranges(RationalTime(value=1, rate=1), (), minimum_duration=0) + + +def test_extract_frame_requires_written_output( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setattr(subprocess, "run", lambda *_args, **_kwargs: completed()) + output = tmp_path / "frame.jpg" + with pytest.raises(FFmpegExecutionError, match="no frame was written"): + shots.extract_frame(Path("clip.mp4"), RationalTime(value=1, rate=2), output) + + output.write_bytes(b"jpeg") + shots.extract_frame(Path("clip.mp4"), RationalTime(value=1, rate=2), output) diff --git a/uv.lock b/uv.lock index 1e0899d..1349e90 100644 --- a/uv.lock +++ b/uv.lock @@ -15,6 +15,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/99/91/8acff4f5e50511b911bbccb72b8628a49c68ce14148cd9f6431094859a90/annotated_types-0.8.0-py3-none-any.whl", hash = "sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0", size = 13427, upload-time = "2026-07-23T20:16:12.938Z" }, ] +[[package]] +name = "anyio" +version = "4.14.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/61/cc/a381afa6efea9f496eff839d4a6a1aed3bfafc7b3ab4b0d1b243a12573dd/anyio-4.14.2.tar.gz", hash = "sha256:cfa139f3ed1a23ee8f88a145ddb5ac7605b8bbfd8592baacd7ce3d8bb4313c7f", size = 260176, upload-time = "2026-07-12T20:29:07.082Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/35/f2287558c17e29fafc8ef3daf819bb9834061cfa43bff8014f7df7f63bdc/anyio-4.14.2-py3-none-any.whl", hash = "sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494", size = 125813, upload-time = "2026-07-12T20:29:05.763Z" }, +] + [[package]] name = "ast-serialize" version = "0.8.0" @@ -79,6 +92,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/25/6c/b400476d3ceba681ab929787edc9554f6d88fcc69435eb681b00fc0457a5/ast_serialize-0.8.0-cp39-abi3-win_arm64.whl", hash = "sha256:b2a5978662fd4db463dfb4b974d2b10ac6430b98f5333aabc7051909df3561d0", size = 1083655, upload-time = "2026-08-07T11:29:00.349Z" }, ] +[[package]] +name = "certifi" +version = "2026.7.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a3/c2/24167ea9858356b47a87a50d39908bfdb72ceeefe0041586e704e5376b3a/certifi-2026.7.22.tar.gz", hash = "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55", size = 138112, upload-time = "2026-07-22T03:35:12.644Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" }, +] + [[package]] name = "colorama" version = "0.4.6" @@ -187,6 +209,61 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b4/d9/e70c286c979378f061d8266e279b686ab0b0b688e1fe0af864684f23a77d/coverage-7.15.4-py3-none-any.whl", hash = "sha256:964730a1e9de9c0cf11be6a1a3c79ce419c34882842abd256086ba4698705e84", size = 214332, upload-time = "2026-08-06T13:50:22.192Z" }, ] +[[package]] +name = "distro" +version = "1.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fc/f8/98eea607f65de6527f8a2e8885fc8015d3e6f5775df186e443e0964a11c3/distro-1.9.0.tar.gz", hash = "sha256:2fa77c6fd8940f116ee1d6b94a2f90b13b5ea8d019b98bc8bafdcabcdd9bdbed", size = 60722, upload-time = "2023-12-24T09:54:32.31Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2", size = 20277, upload-time = "2023-12-24T09:54:30.421Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[[package]] +name = "idna" +version = "3.18" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" }, +] + [[package]] name = "iniconfig" version = "2.3.0" @@ -196,6 +273,74 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, ] +[[package]] +name = "jiter" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1d/1f/10936e16d8860c70698a1aa939a46aa0224813b782bce4e000e637da0b2d/jiter-0.16.0.tar.gz", hash = "sha256:7b24c3492c5f4f84a37946ad9cf504910cf6a782d6a4e0689b6673c5894b4a1c", size = 176431, upload-time = "2026-06-29T13:05:13.657Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/83/2b/52ace16ed031354f0539749a49e4bf33797d82bea5137910835fa4b09793/jiter-0.16.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:67c3bc1760f8c99d805dcab4e644027142a53b1d5d861f18780ebdbd5d40b72a", size = 306943, upload-time = "2026-06-29T13:03:14.035Z" }, + { url = "https://files.pythonhosted.org/packages/94/2e/34957c2c1b661c252ba9bcc60ae0bddc27e0f7202c6073326a13c5390eec/jiter-0.16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5af7780e4a26bd7d0d989592bf9ef12ebf806b74ab709223ecca37c749872ea9", size = 307779, upload-time = "2026-06-29T13:03:15.418Z" }, + { url = "https://files.pythonhosted.org/packages/88/6c/59bd309cab4460c54cf1079f3eb7fe7af6a4c895c5c957a53378693bad2b/jiter-0.16.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d5bf78d0e05e45cfdd66558893938d59afe3d1b1a824a202039b20e607d25a72", size = 335826, upload-time = "2026-06-29T13:03:17.11Z" }, + { url = "https://files.pythonhosted.org/packages/3b/8c/f5ef7b65f0df47afa16596969defb281ebb86e96df346d62be6fd853d620/jiter-0.16.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f4444a83f946605990c98f625cdd3d2725bfb818158760c5748c653170a20e0e", size = 362573, upload-time = "2026-06-29T13:03:18.781Z" }, + { url = "https://files.pythonhosted.org/packages/2b/0b/ace4354da061ee38844a0c27dc2c21eecd27aea119e8da324bea987522d0/jiter-0.16.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3a23f0e4f957e1be65752d2dfac9a5a06b1917af8dc85deb639c3b9d02e31290", size = 457979, upload-time = "2026-06-29T13:03:20.293Z" }, + { url = "https://files.pythonhosted.org/packages/55/40/c0253d3772eb9dcd8e6606ee9b2d53ec8e5b814589c47f140aa585f21eaa/jiter-0.16.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c22a488f7b9218e245a0025a9ba6b100e2e54700831cf4cf16833a27fba3ad01", size = 372302, upload-time = "2026-06-29T13:03:21.739Z" }, + { url = "https://files.pythonhosted.org/packages/a8/d2/4839422241aa12860ce597b20068727094ba0bc480723c74924ca5bad483/jiter-0.16.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:46add52f4ad47a08bfb1219f3e673da972191489a33016edefdb5ea55bfa8c48", size = 343805, upload-time = "2026-06-29T13:03:23.384Z" }, + { url = "https://files.pythonhosted.org/packages/e2/59/e196888a05befdda7dbe299b722d56f2f6eec65402bc34c0a3306d595feb/jiter-0.16.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:9c8a956fd72c2cf1e730d01ea080341f13aa0a97a4a33b51abebe725b7ae9ca9", size = 351107, upload-time = "2026-06-29T13:03:24.815Z" }, + { url = "https://files.pythonhosted.org/packages/ec/74/4cd9e0fca65232136400354b630fbfcd2de634e22ccbb96567725981b548/jiter-0.16.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:561926e0573ffe4a32498420a76d64b16c513e1ab413b9d28158a8764ac701e5", size = 388441, upload-time = "2026-06-29T13:03:26.266Z" }, + { url = "https://files.pythonhosted.org/packages/d9/8c/554691e48bc711299c0a293dd8a6179e24b2d66a54dc295421fcf64569c0/jiter-0.16.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:44d019fa8cdaf89bf29c71b39e3712143fdd0ac76725c6ef954f9957a5ea8730", size = 516354, upload-time = "2026-06-29T13:03:28.02Z" }, + { url = "https://files.pythonhosted.org/packages/a4/cb/01e9d69dc2cc6759d4f91e230b34489c4fdb2518992650633f9e20bece89/jiter-0.16.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:0df91907609837f33341b8e6fe73b95991fdaa57caf1a0fbd343dffe826f386f", size = 547880, upload-time = "2026-06-29T13:03:29.534Z" }, + { url = "https://files.pythonhosted.org/packages/79/70/2953195f1c6ad00f49fa67e13df7e60acb3dd4f387101bc15abccddd905e/jiter-0.16.0-cp312-cp312-win32.whl", hash = "sha256:51d7b836acb0108d7c77df1742332cac2a1fa04a74d6dacec46e7091f0e91274", size = 203473, upload-time = "2026-06-29T13:03:31.025Z" }, + { url = "https://files.pythonhosted.org/packages/2d/05/2909a8b10699a4d560f8c502b6b2c5f3991b682b1922c1eedda242b225bd/jiter-0.16.0-cp312-cp312-win_amd64.whl", hash = "sha256:1878349266f8ee36ecb1375cc5ba2f115f35fd9f0a1a4119e725e379126647f7", size = 196905, upload-time = "2026-06-29T13:03:32.472Z" }, + { url = "https://files.pythonhosted.org/packages/e9/a9/6b82bb1c8d7790d602489b967b982a909e5d092875a6c2ade96444c8dfc5/jiter-0.16.0-cp312-cp312-win_arm64.whl", hash = "sha256:2ed5738ae4af18271a51a528b8811b0cbfa4a1858de9d83359e4169855d6a331", size = 190618, upload-time = "2026-06-29T13:03:34.672Z" }, + { url = "https://files.pythonhosted.org/packages/91/c0/555fc60473d30d66894ba825e63615e3be7524fac23858356afa7a38906c/jiter-0.16.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:41977aa5654023948c2dae2a81cbf9c43343954bef1cd59a154dd15a4d84c195", size = 306203, upload-time = "2026-06-29T13:03:36.243Z" }, + { url = "https://files.pythonhosted.org/packages/d0/2b/c3eaf16f5d7c9bad66ea32f40a95bd169b29a91217fcc7f081375157e99c/jiter-0.16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d28bb3c26762358dadf3e5bf0bccd29ae987d65e6988d2e6f49829c76b003c09", size = 306489, upload-time = "2026-06-29T13:03:37.846Z" }, + { url = "https://files.pythonhosted.org/packages/96/3f/02fdfc6705cad96127d883af5c34e4867f554f29ec7705ec1a46156400a9/jiter-0.16.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0542a7189c26920778658fc8fcf2af8bae05bae9924577f71804acef37996536", size = 335453, upload-time = "2026-06-29T13:03:39.221Z" }, + { url = "https://files.pythonhosted.org/packages/b2/a6/e4bda5920d4b0d7c5dfb7174ce4a6b2e4d3e11c9162c452ef0eab4cdbdbd/jiter-0.16.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8fb8de1e23a0cb2a7f53c335049c7b72b6db41aa6227cdcc0972a1de5cb39450", size = 361625, upload-time = "2026-06-29T13:03:40.597Z" }, + { url = "https://files.pythonhosted.org/packages/b7/97/4e6b59b2c6e55cbb3e183595f81ad65dcfb21c915fee5e19e335df21bc55/jiter-0.16.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b72d0b2990ca754a9102779ac98d8597b7cb31678958562214a007f909eab78e", size = 456958, upload-time = "2026-06-29T13:03:42.074Z" }, + { url = "https://files.pythonhosted.org/packages/15/e0/97e9557686d2f94f4b93786eccb7eed28e9228ad132ea8237f44727314a7/jiter-0.16.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d5f91b1c27fc22a57993d5a5cb8a627cb8ed4b10502716fac1ffbfe1d19d84e8", size = 372017, upload-time = "2026-06-29T13:03:43.658Z" }, + { url = "https://files.pythonhosted.org/packages/0f/94/db768b6938e0df35c86beeba3dfbbb025c9ee5c19e1aa271f2396e50864d/jiter-0.16.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c682bea068a90b764577bdb78a60a4c1d1606daf9cd4c893832a37c7cc9d9026", size = 343320, upload-time = "2026-06-29T13:03:45.226Z" }, + { url = "https://files.pythonhosted.org/packages/c1/d6/5a59d938244a30735fe62d9433fd325f9021ea29d89780ea4596ea93bc89/jiter-0.16.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:8d031aabecc4f1b6276adfb42e3aabb77c89d468bf616600e8d3a11328929053", size = 350520, upload-time = "2026-06-29T13:03:46.671Z" }, + { url = "https://files.pythonhosted.org/packages/67/f8/c4a857f49c9af125f6bbcac7e3eee7f7978ed89682833062e2dbf62576b1/jiter-0.16.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:eab2cd170150e70153de16896a1774e3a1dca80154c56b54d7a812c479a7165e", size = 387550, upload-time = "2026-06-29T13:03:48.361Z" }, + { url = "https://files.pythonhosted.org/packages/8b/d6/5fbc2f7d6b67b754caa61a993a2e626e815dec47ffc2f9e35f01adfebec7/jiter-0.16.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:6edb63a46e65a82c26800a868e49b2cac30dd5a4218b88d74bc2c848c8ad60bb", size = 515424, upload-time = "2026-06-29T13:03:49.881Z" }, + { url = "https://files.pythonhosted.org/packages/ed/54/284f0164b64a5fed915fea6ba7e9ba9b3d8d37c67d59cf2e3bb99d45cdfe/jiter-0.16.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:659039cc50b5addcc35fcc87ae2c1833b7c0a8e5326ef631a75e4478447bcf84", size = 546981, upload-time = "2026-06-29T13:03:51.363Z" }, + { url = "https://files.pythonhosted.org/packages/13/c5/2a467585a576594384e1d2c43e1224deaafc085f24e243529cf98beef8e1/jiter-0.16.0-cp313-cp313-win32.whl", hash = "sha256:c9c53be232c2e206ef9cdbad81a48bfa74c3d3f08bcf8124630a8a748aad993e", size = 202853, upload-time = "2026-06-29T13:03:53.015Z" }, + { url = "https://files.pythonhosted.org/packages/88/6a/de61d04b9eec69c71719968d2f716532a3bc121170c44a39e14979c6be81/jiter-0.16.0-cp313-cp313-win_amd64.whl", hash = "sha256:baad945ed47f163ad833314f8e3288c396118934f94e7bbb9e243ce4b341a4fd", size = 196160, upload-time = "2026-06-29T13:03:54.447Z" }, + { url = "https://files.pythonhosted.org/packages/19/4b/b390ed59bafb3f31d008d1218578f10327714484b334439947f7e5b11e7f/jiter-0.16.0-cp313-cp313-win_arm64.whl", hash = "sha256:3c1fd2dbe1b0af19e987f03fe66c5f5bd105a2229c1aff4ab14890b24f41d21a", size = 189862, upload-time = "2026-06-29T13:03:55.754Z" }, + { url = "https://files.pythonhosted.org/packages/a7/89/bc4f1b57d5da938fd344a466396541e586d161320d70bffd929aaafcd8f4/jiter-0.16.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:b2c61484666ad42726029af0c00ef4541f0f3b5cdc550221f56c2343208018ee", size = 308239, upload-time = "2026-06-29T13:03:57.205Z" }, + { url = "https://files.pythonhosted.org/packages/65/7a/c415453e5213001bf3b411ff65dec3d303b0e76a4a2cfea9768cd4960994/jiter-0.16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:63efadc657488f45db1c676d81e704cac2abf3fdb892def1faea61db053127e2", size = 308928, upload-time = "2026-06-29T13:03:58.643Z" }, + { url = "https://files.pythonhosted.org/packages/11/fc/1f4fb7ebf9a724c7741994f4aae18fba1e2f3133df14521a79194952c34a/jiter-0.16.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cf0d73f50e7b6935677854f6e8e31d499ca7064dd24734f703e060f5b237d883", size = 336998, upload-time = "2026-06-29T13:04:00.071Z" }, + { url = "https://files.pythonhosted.org/packages/a0/8d/72cadaac05ccfa7cc3a0a2232862e6c72443ca40cf300ba8b57f9f18b69b/jiter-0.16.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bf3ea07d9bc8e7d03a9fbc051295462e6dbc295b894fd72457c3136e3e43d898", size = 362112, upload-time = "2026-06-29T13:04:01.52Z" }, + { url = "https://files.pythonhosted.org/packages/58/4a/c4b0d5f651fda90a24ffce9f8d56cde462a2e09d31ae3de3c68cef34c04e/jiter-0.16.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:26798522707abb47d767db536e4148ceac1b14446bf028ee85e579a2e043cfe5", size = 459807, upload-time = "2026-06-29T13:04:03.214Z" }, + { url = "https://files.pythonhosted.org/packages/80/58/ef77879ea9aa56b50824edc5a445e226422c7a8d211f3fd2a56bcb9493cf/jiter-0.16.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bc837c1b9631be10abfe0191537fe8009838204cec7e44827401ace390ddb567", size = 373181, upload-time = "2026-06-29T13:04:04.629Z" }, + { url = "https://files.pythonhosted.org/packages/49/2e/ffbc3f254e4d8a66da3062c624a7df4b7c2b2cf9e1fe43cf394b3e104041/jiter-0.16.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:49060fd70737fad59d33ba9dcc0d83247dc9e77187de26053a19c16c9f32bd69", size = 344927, upload-time = "2026-06-29T13:04:06.067Z" }, + { url = "https://files.pythonhosted.org/packages/9a/f6/0be5dc6d64a89f80aa8fec984f94dedb2973e251edcae55841d60786d578/jiter-0.16.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:adbb8edeadd431bc4477879d5d371ece7cb1334486584e0f252656dd7ffada29", size = 352754, upload-time = "2026-06-29T13:04:07.477Z" }, + { url = "https://files.pythonhosted.org/packages/da/6e/7d31243b3b91cd261dd19e9d3557fc3251a80883d3d8049c86174e7ab7af/jiter-0.16.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:31aaee5b80f672c1dc21272bcfb9cbdcfc1ea04ff50f00ed5af500b80c44fa93", size = 390553, upload-time = "2026-06-29T13:04:08.92Z" }, + { url = "https://files.pythonhosted.org/packages/25/33/51ae371fde3c88897520f62b4d5f8b27ad7103e2bb10812ff52195609853/jiter-0.16.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:6722bcef4ffc86c835574b1b2fac6b33b9fb4a889c781e67950e891591f3c55a", size = 516900, upload-time = "2026-06-29T13:04:10.407Z" }, + { url = "https://files.pythonhosted.org/packages/a0/45/6449b3d123ea439ba79507c657288f461d55049e7bcbdc2cf8eb8210f491/jiter-0.16.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:5ab4f50ff971b611d656554ea10b75f80097392c827bc32923c6eeb6386c8b00", size = 548754, upload-time = "2026-06-29T13:04:12.046Z" }, + { url = "https://files.pythonhosted.org/packages/9b/e7/fd2fb11ae3e2649333da3aa170d04d7b3000bbdc3b270f6513382fdf4e04/jiter-0.16.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:710cc51d4ebdcd3c1f70b232c1db1ea1344a075770422bbd4bede5708335acbe", size = 122381, upload-time = "2026-06-29T13:04:13.413Z" }, + { url = "https://files.pythonhosted.org/packages/26/80/f0b147a62c315a164ed2168908286ca302310824c218d3aae52b06c0c9a9/jiter-0.16.0-cp314-cp314-win32.whl", hash = "sha256:57b37fc887a32d44798e4d8ebfa7c9683ff3da1d5bf38f08d1bb3573ccb39106", size = 204578, upload-time = "2026-06-29T13:04:14.813Z" }, + { url = "https://files.pythonhosted.org/packages/5e/e6/4758a14304b4523a6f5adb2419340086aa3593bd4327c2b25b5948a90548/jiter-0.16.0-cp314-cp314-win_amd64.whl", hash = "sha256:cbd18dd5e2df96b580487b5745adf57ef64ad89ba2d9662fc3c19386acce7db8", size = 198154, upload-time = "2026-06-29T13:04:16.272Z" }, + { url = "https://files.pythonhosted.org/packages/26/be/41fa54a2e7ea41d6c99f1dc5b1f0fd4cb474680304b5d268dd518e81da3a/jiter-0.16.0-cp314-cp314-win_arm64.whl", hash = "sha256:a32d2027a9fa67f109ff245a3252ece3ccc32cc56703e1deab6cc846a59e0585", size = 191458, upload-time = "2026-06-29T13:04:17.707Z" }, + { url = "https://files.pythonhosted.org/packages/81/6b/59127338b86d9fe4d99418f5a15118bea778103ee0fe9d9dd7e0af174e95/jiter-0.16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2577196f4474ef3fc4779a088a23b0897bbf86f9ea3679c372d45b8383b43207", size = 316739, upload-time = "2026-06-29T13:04:19.663Z" }, + { url = "https://files.pythonhosted.org/packages/2d/95/49461034d5388196d3dabf98748935f017b7785d8f3f5349f834bcc4ed0d/jiter-0.16.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:616e89e008a93c01104161c75b4988e58716b01d62307ebfe161e52a56d2a818", size = 340911, upload-time = "2026-06-29T13:04:21.257Z" }, + { url = "https://files.pythonhosted.org/packages/cd/97/a4369f2fb82cb3dda13b98622f31249b2e014b223fe64ee534413ad72294/jiter-0.16.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0e2e9efbe042210df657bade597f66d6d75723e3d8f45a12ea6d8167ff8bbce3", size = 361747, upload-time = "2026-06-29T13:04:22.677Z" }, + { url = "https://files.pythonhosted.org/packages/28/51/49b6ed456261646e1906016a6760367a28aacd3c24805e4e5fe64116c1db/jiter-0.16.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3f4d9e473a5ce7d27fef8b848df4dc16e283893d3f53b4a585e72c9595f3c284", size = 460225, upload-time = "2026-06-29T13:04:24.441Z" }, + { url = "https://files.pythonhosted.org/packages/33/b5/5689aff4f66c5b60be63106e591dbfcba2190df97d2c9c7cf052361ddb98/jiter-0.16.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8d30a4a1c87713060c8d1cc59a7b6c8fb6b8ef0a6900368014c76c87922a2929", size = 373169, upload-time = "2026-06-29T13:04:25.884Z" }, + { url = "https://files.pythonhosted.org/packages/a2/96/3ae1b85ee0d6d6cab254fb7f8da018272b932bbf2d69b07e98aa2a96c746/jiter-0.16.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bae96332410f866e5900d809298b1ed82735932986c672495f9701daacd80620", size = 350332, upload-time = "2026-06-29T13:04:27.302Z" }, + { url = "https://files.pythonhosted.org/packages/15/32/c99d7bafd78986556c95bf60ce84c6cc98786eac56066c12d7f828bb6747/jiter-0.16.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:da3d7ec75dc83bb18bca888b5edfae0656a26849056c59e05a7728badd17e7af", size = 353377, upload-time = "2026-06-29T13:04:28.731Z" }, + { url = "https://files.pythonhosted.org/packages/0e/4b/f99a8e571287c3dec766bcc18528bbe8e8fb5365522ab5e6d64c93e87066/jiter-0.16.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ee6162b77d49a9939229df666dfa8af3e656b6701b54c4c84966d740e189264e", size = 387746, upload-time = "2026-06-29T13:04:30.319Z" }, + { url = "https://files.pythonhosted.org/packages/75/69/c78a5b3f71040e34eb5917df26fb7ae9a2174cad1ccbf277512507c53a6e/jiter-0.16.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:63ffdbdae7d4499f4cda14eadc12ddcabef0fc0c081191bdc2247489cb698077", size = 517292, upload-time = "2026-06-29T13:04:31.709Z" }, + { url = "https://files.pythonhosted.org/packages/c2/f7/095b38eda4c70d03651c403f29a5590f16d12ddc5d544aac9f9cddf72277/jiter-0.16.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:a111256a7193bea0759267b10385e5870949c239ed7b6ddbaaf57573edb38734", size = 549259, upload-time = "2026-06-29T13:04:33.721Z" }, + { url = "https://files.pythonhosted.org/packages/2e/c5/6a0207d90e5f656d95af98ebd0934f382d37674416f215aeda2ff8063e51/jiter-0.16.0-cp314-cp314t-win32.whl", hash = "sha256:de5ba8763e56b793561f43bed197c9ea55776daa5e9a6b91eed68a909bc9cdbf", size = 206523, upload-time = "2026-06-29T13:04:35.068Z" }, + { url = "https://files.pythonhosted.org/packages/a5/31/c757d5f30a8980fd945ce7b98be10be9e4ff59c7c42f5fd86804c2e87db8/jiter-0.16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b8a3f9a6008048fe9def7bf465180564a6e458047d2ce499149cfbe73c3ae9db", size = 200366, upload-time = "2026-06-29T13:04:36.61Z" }, + { url = "https://files.pythonhosted.org/packages/7c/a2/d88de6d313d734a544a7901353ad5db67cb38dcfcd91713b7979dafc345d/jiter-0.16.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0fa25b09b13075c46f5bc174f2690525a925a4fc2f7c82969a2bbabff22386ce", size = 190516, upload-time = "2026-06-29T13:04:38.004Z" }, + { url = "https://files.pythonhosted.org/packages/98/ab/664fd8c4be028b2bedd3d2ff08769c4ede23d0dbc87a77c62384a0515b5d/jiter-0.16.0-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:f17d61a28b4b3e0e3e2ba98490c70501403b4d196f78732439160e7fd3678127", size = 303106, upload-time = "2026-06-29T13:05:07.118Z" }, + { url = "https://files.pythonhosted.org/packages/1a/07/421f1d5b65493a76e16027b848aba6a7d28073ae75944fa4289cc914d39f/jiter-0.16.0-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:96e38eea538c8ddf853a35727c7be0741c76c13f04148ac5c116222f50ece3b3", size = 304658, upload-time = "2026-06-29T13:05:08.708Z" }, + { url = "https://files.pythonhosted.org/packages/0a/db/bba1155f01a01c3c37a89425d571da751bbedf5c54247b831a04cb971798/jiter-0.16.0-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d284fb8d94d5855d60c44fefcab4bf966f1da6fada73992b01f6f0c9bc0c6702", size = 339719, upload-time = "2026-06-29T13:05:10.41Z" }, + { url = "https://files.pythonhosted.org/packages/78/f7/18a1afcd64f35314b68c1f23afcd9994d0bc13e65cc77517afff4e83986d/jiter-0.16.0-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:64d613743df53199b1aa256a7d328340da6d7078aac7705a7db9d7a791e9cfd2", size = 343885, upload-time = "2026-06-29T13:05:12.087Z" }, +] + [[package]] name = "librt" version = "0.15.0" @@ -351,6 +496,25 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, ] +[[package]] +name = "openai" +version = "2.53.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "distro" }, + { name = "httpx" }, + { name = "jiter" }, + { name = "pydantic" }, + { name = "sniffio" }, + { name = "tqdm" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ef/cf/36e3e7235fdf6d125c052acc0970924611b17a20a4fe580596faf4566a65/openai-2.53.0.tar.gz", hash = "sha256:baf5802ad08980e1d9d561e1b996e800c8bcd14af5847c6d0e7a5cc59e4d4116", size = 1099435, upload-time = "2026-08-03T21:42:01.664Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/0f/cc6afea3542a5142c5d8fc8211c5e059a8375105d004a41dfa2c7948dbb0/openai-2.53.0-py3-none-any.whl", hash = "sha256:c694ffc747a3c4d1663ef2b07b811315a476164ee5efa3a993967349ebca7618", size = 1659829, upload-time = "2026-08-03T21:41:59.581Z" }, +] + [[package]] name = "packaging" version = "26.3" @@ -540,6 +704,11 @@ dependencies = [ { name = "pydantic" }, ] +[package.optional-dependencies] +openai = [ + { name = "openai" }, +] + [package.dev-dependencies] dev = [ { name = "mypy" }, @@ -549,7 +718,11 @@ dev = [ ] [package.metadata] -requires-dist = [{ name = "pydantic", specifier = ">=2.11,<3" }] +requires-dist = [ + { name = "openai", marker = "extra == 'openai'", specifier = ">=1.0" }, + { name = "pydantic", specifier = ">=2.11,<3" }, +] +provides-extras = ["openai"] [package.metadata.requires-dev] dev = [ @@ -559,6 +732,27 @@ dev = [ { name = "ruff", specifier = ">=0.12" }, ] +[[package]] +name = "sniffio" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, +] + +[[package]] +name = "tqdm" +version = "4.70.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/21/3b/6c24bec5be5e743ffd99576daa5cc077722fc7d5bbc00bd133fa0c698dc6/tqdm-4.70.0.tar.gz", hash = "sha256:55b0b0dbd97462d06ebee91e4dac24ed4d4702be82b24f07e6c1d27e08cea220", size = 795438, upload-time = "2026-07-27T11:33:15.271Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f9/1c/01bfd571a64e7f270e6bab5e33777debe0edc56759233ce84f27dec92d14/tqdm-4.70.0-py3-none-any.whl", hash = "sha256:7f585706bfddbdebf89daac705b2dfcc16890130727d3197ca62c732b4310953", size = 80184, upload-time = "2026-07-27T11:33:13.167Z" }, +] + [[package]] name = "typing-extensions" version = "4.16.0"