Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/architecture/platform.md
Original file line number Diff line number Diff line change
Expand Up @@ -1090,9 +1090,11 @@ Initial curated tools:
- `get_capability`
- `list_media`
- `get_media`
- `export_subtitles`
- Streamable HTTP: `create_media_upload`, `get_media_upload`
- Filesystem-accessible stdio: `ingest_local_media`, `get_media_ingestion`
- `get_index_status`
- `plan_bulk_index`
- `start_indexing`
- `search_moments`
- `query_video`
Expand Down
28 changes: 28 additions & 0 deletions docs/local-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,34 @@ do not share a filesystem.
The [Premiere Pro extension preview](integrations/premiere-pro.md) uses this
workflow for media already loaded in a Premiere project.

### Plan bulk indexing before submitting jobs

A client can preview which registered media still need indexing without
submitting redundant durable jobs. Send `POST /api/v1/index/plan` with target
`modalities` and optional `media_ids`:

```bash
curl -X POST http://127.0.0.1:32191/api/v1/index/plan \
-H "Content-Type: application/json" \
-d '{"modalities": ["scene"]}'
```

The returned plan categorizes media into pending and skipped targets, recording
the exact skip reason (such as already covered by the active snapshot with
matching content checksums).

### Export transcribed subtitles

A client can export speech transcripts and timed subtitles for speech-indexed
media items in `.srt`, `.vtt`, or JSON format:

```bash
curl http://127.0.0.1:32191/api/v1/media/{media_id}/subtitles?format=srt
```

Pass `format=vtt` for WebVTT subtitles or `format=json` for machine-readable
cue timestamps and transcript text.

## Connect a local AI assistant

An assistant that can start a program on the same computer does not need the
Expand Down
23 changes: 22 additions & 1 deletion src/vidxp/api_routes/index.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,11 @@
from fastapi import APIRouter, Depends

from vidxp.api_routes.dependencies import context, read_principal
from vidxp.application_models import IndexStatus
from vidxp.application_models import (
BulkIndexPlan,
IndexStatus,
PlanBulkIndexCommand,
)
from vidxp.composition import HttpApplicationContext


Expand All @@ -24,3 +28,20 @@ def get_index_status(
service: Annotated[HttpApplicationContext, Depends(context)],
) -> IndexStatus:
return service.application.index_status()


@router.post(
"/plan",
response_model=BulkIndexPlan,
operation_id="planBulkIndex",
summary="Plan bulk indexing",
description=(
"Decide which registered media still need indexing before submitting "
"durable indexing jobs."
),
)
def plan_bulk_index(
command: PlanBulkIndexCommand,
service: Annotated[HttpApplicationContext, Depends(context)],
) -> BulkIndexPlan:
return service.application.plan_bulk_index(command)
25 changes: 25 additions & 0 deletions src/vidxp/api_routes/media.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,14 @@
ApplicationError,
CreateUploadIntentCommand,
ErrorCategory,
ExportSubtitlesCommand,
ListMediaCommand,
LocalMediaIngestionCommand,
MediaAsset,
MediaPage,
MediaUploadSessionStatus,
Principal,
SubtitleFormat,
UploadIntent,
UploadIntentId,
UploadSessionId,
Expand Down Expand Up @@ -326,3 +328,26 @@ def head_media_content(
service: Annotated[HttpApplicationContext, Depends(context)],
) -> Response:
return _content(media_id, request, service)


@router.get(
"/{media_id}/subtitles",
response_model=None,
operation_id="exportMediaSubtitles",
summary="Export transcribed subtitles for speech-indexed media",
dependencies=[Depends(read_principal)],
)
def export_media_subtitles(
media_id: MediaId,
service: Annotated[HttpApplicationContext, Depends(context)],
format: SubtitleFormat = SubtitleFormat.srt,
) -> Response:
result = service.application.export_subtitles(
ExportSubtitlesCommand(media_id=media_id, format=format)
)
if format == SubtitleFormat.json:
return Response(content=result.content, media_type="application/json")
if format == SubtitleFormat.vtt:
return Response(content=result.content, media_type="text/vtt")
return Response(content=result.content, media_type="text/plain; charset=utf-8")

45 changes: 45 additions & 0 deletions src/vidxp/application.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

from dataclasses import replace

import json
from contextlib import contextmanager
from pathlib import Path
from shutil import which
Expand All @@ -24,6 +25,7 @@
DependencyKind,
DependencyUnavailableError,
ErrorCategory,
ExportSubtitlesCommand,
FusedSearchResult,
IndexResult,
ImportMediaCommand,
Expand All @@ -43,7 +45,10 @@
EvidenceBoardJobRequest,
EvidenceBoardResult,
EvidenceDeliveryPolicy,
SubtitleExportResult,
SubtitleFormat,
)
from vidxp.subtitles import format_srt, format_vtt, records_to_cues
from vidxp.capabilities.actor.schemas import (
ActorClusterSummary,
ActorClustersOutput,
Expand Down Expand Up @@ -945,6 +950,46 @@ def remove_from_index(self, command: RemoveIndexCommand) -> bool:
command.media_id,
)

@application_boundary
def export_subtitles(
self,
command: ExportSubtitlesCommand,
) -> SubtitleExportResult:
self.get_media(command.media_id)
config = self._active_config()
self._require_indexed_capability("speech", config)
snapshot = self._read_active_snapshot()
if snapshot is not None:
if command.media_id not in snapshot.generations:
raise ApplicationError(
"media_not_indexed",
ErrorCategory.validation,
f"Media '{command.media_id}' is not present in the active index snapshot.",
details={"media_id": command.media_id},
)
if "speech" not in snapshot.generations[command.media_id].modalities:
raise ApplicationError(
"speech_not_indexed",
ErrorCategory.validation,
f"Media '{command.media_id}' was indexed without speech transcription.",
details={"media_id": command.media_id},
)
with self.index_backend.open_store(config) as storage:
raw_records = storage.records("speech", video_id=command.media_id)
cues = records_to_cues(raw_records)
if command.format == SubtitleFormat.vtt:
content = format_vtt(cues)
elif command.format == SubtitleFormat.json:
content = json.dumps([cue.model_dump(mode="json") for cue in cues], indent=2) + "\n"
else:
content = format_srt(cues)
return SubtitleExportResult(
media_id=command.media_id,
format=command.format,
content=content,
cues=tuple(cues),
)

def _base_config(self) -> IndexConfig:
return IndexConfig.local(
storage_directory=self.index_directory,
Expand Down
30 changes: 30 additions & 0 deletions src/vidxp/application_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -729,6 +729,36 @@ class RemoveIndexCommand(ApplicationModel):
media_id: MediaId


class SubtitleFormat(StrEnum):
srt = "srt"
vtt = "vtt"
json = "json"


class SubtitleCue(ApplicationModel):
start: float = Field(ge=0, description="Cue start time in seconds.")
end: float = Field(ge=0, description="Cue end time in seconds.")
text: str = Field(min_length=1, description="Transcribed speech text.")


class ExportSubtitlesCommand(ApplicationModel):
media_id: MediaId = Field(description="Cataloged media identifier.")
format: SubtitleFormat = Field(
default=SubtitleFormat.srt,
description="Subtitle output format.",
)


class SubtitleExportResult(ApplicationModel):
media_id: MediaId
format: SubtitleFormat
content: str = Field(description="Formatted subtitle text.")
cues: tuple[SubtitleCue, ...] = Field(
default=(),
description="Structured cues with timestamps.",
)


class Artifact(ApplicationModel):
schema_version: Literal[ARTIFACT_SCHEMA_VERSION] = ARTIFACT_SCHEMA_VERSION
artifact_id: ArtifactId
Expand Down
61 changes: 60 additions & 1 deletion src/vidxp/cli_commands/media.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,12 @@
from rich.console import Console
from rich.table import Table

from vidxp.application_models import ImportMediaCommand, ListMediaCommand
from vidxp.application_models import (
ExportSubtitlesCommand,
ImportMediaCommand,
ListMediaCommand,
SubtitleFormat,
)
from vidxp.cli_support import (
OutputFormat,
effective_output_format,
Expand Down Expand Up @@ -141,3 +146,57 @@ def show_media(
emit_json(payload)
else:
Console().print_json(data=payload)


@app.command("subtitles")
def export_subtitles(
ctx: typer.Context,
media_id: Annotated[
str,
typer.Argument(
help="Stable media identifier returned by import or list."
),
],
format: Annotated[
SubtitleFormat,
typer.Option(
"--format",
"-f",
help="Subtitle format: srt, vtt, or json.",
case_sensitive=False,
),
] = SubtitleFormat.srt,
output: Annotated[
Path | None,
typer.Option(
"--output",
"-o",
help="Optional file path to write the subtitles to.",
),
] = None,
json_output: Annotated[
bool,
typer.Option("--json", help="Emit machine-readable JSON."),
] = False,
) -> None:
"""Export transcribed speech subtitles (.srt, .vtt, or JSON) for indexed media."""

state = state_from_context(ctx)
result = state.service.export_subtitles(
ExportSubtitlesCommand(media_id=media_id, format=format)
)
if effective_output_format(state, json_output) == OutputFormat.json:
emit_json(result.model_dump(mode="json"))
return

if output is not None:
output.parent.mkdir(parents=True, exist_ok=True)
output.write_text(result.content, encoding="utf-8")
if not state.quiet:
typer.secho(
f"Exported {format.value.upper()} subtitles to {output}",
fg=typer.colors.GREEN,
)
else:
typer.echo(result.content.rstrip("\n"))

9 changes: 9 additions & 0 deletions src/vidxp/control_plane.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
ComponentReadiness,
CreateIndexCommand,
DependencyCheckResult,
ExportSubtitlesCommand,
IndexStatus,
Identifier,
InvalidRequestError,
Expand All @@ -27,6 +28,7 @@
ModelUnavailableError,
ResourceNotFoundError,
RuntimeReadiness,
SubtitleExportResult,
WorkspaceCapability,
WorkspaceMedia,
WorkspaceMediaCapability,
Expand Down Expand Up @@ -474,3 +476,10 @@ def runtime_readiness(self) -> RuntimeReadiness:
components=components,
dependencies=models,
)

def export_subtitles(
self,
command: ExportSubtitlesCommand,
) -> SubtitleExportResult:
raise NotImplementedError

Loading
Loading