Skip to content
Merged
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
7 changes: 4 additions & 3 deletions docs/content/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -157,7 +157,7 @@ stills in filename order, and two calls can never disagree.

**An asset with no recorded arrival sorts last.** That is every asset ingested before
`Asset.ingested_at` existed, and it cannot be backfilled — the information exists nowhere, and `Source.registered_at` is not
the proxy it looks like, because registration is idempotent on `(kind, path, extraction_fps)`
the proxy it looks like, because registration is idempotent on `(kind, path, extraction_fps, ranges)`
and is never rewritten. Sorting them last is the only reading that degrades quietly: treating
the missing value as the epoch invents a date, and treating it as *now* would pin the oldest
rows in the product to the top of a "recent" list forever. A workspace that has ingested nothing
Expand Down Expand Up @@ -349,8 +349,9 @@ one fatal cause, `failures` for the per-item report - because by then there is n
to answer.

**Uploads are multipart, and the only non-JSON request shape.** Registering a source means
sending the bytes: one `files` part per image, or one `file` part plus an `extraction_fps` field
for a clip. VisionSet sets **no size limit of its own** - parts are spooled to disk past 1 MiB
sending the bytes: one `files` part per image, or one `file` part plus `extraction_fps` — and
optionally `ranges`, a JSON array of half-open `{start_seconds, end_seconds}` stretches to
extract — for a clip. VisionSet sets **no size limit of its own** - parts are spooled to disk past 1 MiB
and streamed from there, so memory does not grow with the file - which means the real ceilings
are your reverse proxy's (`client_max_body_size` in nginx) and free disk. Uploaded bytes are
staged under `<workspace>/uploads/<digest>/` and, like blobs, are **never deleted**: a workspace
Expand Down
9 changes: 6 additions & 3 deletions docs/content/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ visionset schema draft set FILE --project P [--kind K] [--note TEXT] [--revision
visionset schema draft clear --project P [--kind K]
visionset schema draft publish --project P [--kind K] [--revision N] [--allow-destructive]

visionset ingest PATH --project P [--fps N] [--batch-name NAME]
visionset ingest PATH --project P [--fps N] [--range S:E]... [--batch-name NAME]
visionset batch list --project P
visionset batch approve BATCH_ID [--jobs-of N]
visionset batch pre-label BATCH_ID CONNECTION [--minimum-confidence FLOAT] [--replace-model-labels] [--geometry SHAPE]...
Expand Down Expand Up @@ -343,13 +343,16 @@ name, no geometry, a select with no options - is refused there, named by its pos

### `visionset ingest`

`PATH --project P [--fps N] [--batch-name NAME]` - **the one command that is two SDK calls**:
`PATH --project P [--fps N] [--range S:E]... [--batch-name NAME]` - **the one command that is
two SDK calls**:
`SourceService.register_images` or `register_video`, dispatched on whether the path is a directory,
then `IngestService.ingest`. Registration is idempotent, so re-running the same line registers once;
content addressing means it also creates no asset it created before, which is the remedy for an
interrupted run. The batch id goes to stdout.

`--fps` is video-only and a usage error on a folder. The run is **synchronous**, and there is no
`--fps` and `--range` are video-only and usage errors on a folder. `--range START:END` repeats,
in seconds, and the selection is stored canonically - clamped to the clip, sorted, overlapping
and touching ranges merged. The run is **synchronous**, and there is no
`--resume`: polling needs a second process, which is what `visionset server` and
`GET /ingest-jobs/{id}` are for. See [ingest.md](ingest.md#at-a-terminal).

Expand Down
30 changes: 22 additions & 8 deletions docs/content/ingest.md
Original file line number Diff line number Diff line change
Expand Up @@ -231,10 +231,13 @@ reported.frames_produced # 8 — exact, and the assets are in the batch
reported.frames_expected_estimate # 20 — an estimate, and may be None
```

`frames_produced` is the length of what the loop kept. `frames_expected_estimate` is
`duration_seconds × extraction_fps` off the probe the source has carried since it was registered
- the same arithmetic the ingest screen shows as "Frames expected" before a run starts - so no
second pass over the clip is made for it. It is named an estimate because it is one:
`frames_produced` is the length of what the loop kept. `frames_expected_estimate` is the
domain's own count over the probe and the clip ranges the source has carried since it was
registered — per half-open range `ceil(end × fps) − ceil(start × fps)`, the whole clip
`ceil(duration × fps)`. The same arithmetic builds the extraction filter and the ingest
screen's "Frames expected", so the three cannot disagree, and no second pass over the clip is
made for it; an earlier `floor` spelling undercounted by one on every fractional product.
It is still named an estimate because here it is one:
`VideoMetadata` deliberately carries no frame count (for a variable-rate stream the product is a
guess), and a damaged container's own metadata is suspect besides. A partial with no denominator
still states what it recovered.
Expand Down Expand Up @@ -343,7 +346,7 @@ they have, because the filesystem already knows, and the source they end up with
the path and the rate from then on.

It is the only command in the CLI that is two SDK calls, and its module says so. Both are safe to
repeat: registration is idempotent on `(kind, path, extraction_fps)`, and content addressing means a
repeat: registration is idempotent on `(kind, path, extraction_fps, ranges)`, and content addressing means a
second run creates nothing the first already did.

The batch id goes to stdout alone, so `BATCH=$(visionset ingest …)` is the whole idiom. The per-file
Expand All @@ -370,7 +373,7 @@ posture the kernel takes about a crashed process.
The [API](api.md) is `enqueue` and `resume` with a worker between them.

```
POST /projects/{id}/sources/video multipart: the clip + extraction_fps → 201 SourceOut
POST /projects/{id}/sources/video multipart: the clip + extraction_fps + ranges → 201 SourceOut
POST /sources/{id}/ingest-jobs → 202 IngestJobOut
GET /ingest-jobs/{id} → 200 IngestJobOut
GET /batches/{id}/assets → 200 the assets
Expand Down Expand Up @@ -413,8 +416,19 @@ for them. See [batches.md](batches.md).
`@visionset/ui-core`'s ingest screen is three steps, and their order is forced by
the two facts on this page rather than chosen: **`extraction_fps` belongs to the
source**, so it is picked before anything is probed, and the probe only exists once
the source is registered. Registering the same clip at another rate creates a second
source, which the screen says out loud.
the source is registered. Registering the same clip at another rate — or over other
clip ranges — creates a second source, which the screen says out loud.

A chosen clip the browser can decode also gets a preview player and a timeline:
dragging on it selects one or more **clip ranges**, half-open stretches extraction
alone will read. The selection is part of the source, like the rate. It is stored
canonically — clamped to the clip, sorted, overlapping and touching ranges merged,
a full cover collapsing to the empty selection — and the probe card echoes that
canonical form back, which is where an overlapping selection is first seen merged.
The frame count beside the rate is exact: the same
`ceil(end × fps) − ceil(start × fps)` per range that builds the extraction filter.
A clip the browser cannot decode gets no timeline and one line saying it will be
ingested whole; registration proceeds unchanged.

It shows `processed` against `total` for a directory and a bare count for a clip
(there is no denominator until an extraction is over), groups the per-file report by
Expand Down
2 changes: 1 addition & 1 deletion docs/content/mcp-tools.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ error envelope, and the three gate words.
| `set_schema_draft` | `project`, `classes`, `kind`?, `note`?, `revision`? | Write the whole draft, creating it when there is none. |
| `publish_schema_draft` | `project`, `revision`, `kind`?, `allow_destructive`? | Turn the draft into the next schema version, and clear it. |
| `clear_schema_draft` | `project`, `kind`? | Throw the draft away without publishing it. |
| `ingest` | `project`, `path`, `fps`?, `batch_name`? | Register a source and read it into one batch. Blocks until the run finishes. |
| `ingest` | `project`, `path`, `fps`?, `ranges`?, `batch_name`? | Register a source and read it into one batch. Blocks until the run finishes. |
| `list_sources` | `project` | List the origins registered in a project — the folders and clips it was built from. |
| `backfill_thumbnails` | `project` | Render the previews that are missing for a project's assets. |
| `list_batches` | `project` | List a project's batches with where each one's assets have got to. |
Expand Down
19 changes: 12 additions & 7 deletions docs/content/persistence.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,13 +48,14 @@ never raises `ProjectNameTaken`.
But a rule with no backstop is a wish, so the store carries the constraint too:
`uq_project_workspace_name` on `project (workspace_id, name COLLATE NOCASE)`, alongside
`uq_schema_project_version`, `uq_schema_draft_project_kind`, `uq_member_dataset_asset`,
`uq_release_dataset_tag`, `uq_asset_project_content_hash`, `uq_source_project_kind_path_fps`,
`uq_release_dataset_tag`, `uq_asset_project_content_hash`, `uq_source_project_kind_path_fps_ranges`,
`uq_annotation_asset_classification`, `uq_token_workspace_name` and
`uq_inference_connection_name`. The invariant then survives
a service bug, a forgotten code path, and a second process.

`uq_source_project_kind_path_fps` is one of the two whose terms are not all columns: its fourth
is `coalesce(json_extract(video, '$.extraction_fps'), 0)`. SQLite treats NULLs in a unique index as
`uq_source_project_kind_path_fps_ranges` is one of the two whose terms are not all columns: its
fourth is `coalesce(json_extract(video, '$.extraction_fps'), 0)` and its fifth
`coalesce(json_extract(video, '$.ranges'), '')`. SQLite treats NULLs in a unique index as
distinct, so a nullable column would let every image directory collide with nothing at all — and
an index is not a query, so no service gains a JSON path from it. That is also why neither it nor
`uq_annotation_asset_classification`, which is partial, can use `checkfirst`: SQLAlchemy cannot
Expand Down Expand Up @@ -167,11 +168,12 @@ MIGRATIONS: list[Migration] = [
Migration(version=13, name="credential_env", upgrade=_add_credential_env),
Migration(version=14, name="project_created_at", upgrade=_add_project_created_at),
Migration(version=15, name="connection_origin", upgrade=_add_connection_origin),
Migration(version=16, name="source_clip_ranges", upgrade=_reshape_source_origin_index),
]
FORMAT_VERSION: int = MIGRATIONS[-1].version # 15
FORMAT_VERSION: int = MIGRATIONS[-1].version # 16
```

**Generation 1 is the baseline, and the fourteen entries after it are ordinary migrations.** A long
**Generation 1 is the baseline, and the fifteen entries after it are ordinary migrations.** A long
chain of generations got this schema to its present shape while VisionSet was unreleased.
Every database they could have upgraded was disposable test data inside this repository, so
what they actually bought was an idempotency argument and an undo line per generation, plus
Expand Down Expand Up @@ -205,6 +207,9 @@ so it backfills from the kind with the domain's own rule rather than leaving NUL
something a fresh row would never mean. Where the value is unknowable here, the
column arrives NULL and something outside the kernel fills it in later - and the column's own
docstring says which, so a reader does not mistake an honest absence for a forgotten step.
Migration 16 adds no column at all: it reshapes the source-origin index to carry the
canonical-ranges term, and backfills nothing — a row written before ranges existed has no
`$.ranges` key, which the new index reads as the same `''` a whole-clip selection stores.

**There are no downgrade paths, deliberately.** Nothing walks a file backwards and the
tests no longer do either. A downgrade is a compatibility promise and a promise is owed
Expand Down Expand Up @@ -278,8 +283,8 @@ object with `_tables` rather than repeating the DDL - `checkfirst=True` on a `Ta
**SQLAlchemy cannot reflect a partial or expression-based index**, so `checkfirst` reports
one absent and re-issues a `CREATE` that then fails on every fresh database. Those ask
SQLite instead, via `CreateIndex(index, if_not_exists=True)`. Two indexes here are in that
category: `uq_source_project_kind_path_fps` (its fourth term is
`coalesce(json_extract(video, '$.extraction_fps'), 0)`) and
category: `uq_source_project_kind_path_fps_ranges` (its fourth and fifth terms are
`json_extract` expressions over `video`) and
`uq_annotation_asset_classification` (partial, on the tag geometry).

**A column arriving by `ALTER` is declared last on its row class**, because SQLite appends
Expand Down
27 changes: 17 additions & 10 deletions docs/content/sources.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,10 @@ name, else the path's last segment, and it is what both wire projections publish
`register_images` takes it, because a clip's basename is already its filename.

`VideoProvenance` is the port's own `VideoMetadata` — original fps, duration, displayed
dimensions, codec — plus the `extraction_fps` a decomposition will run at. The probe result is
dimensions, codec — plus the cut a decomposition will run at: `extraction_fps`, and the clip
`ranges` extraction reads, empty meaning the whole clip. Ranges are stored canonically —
clamped to the clip, sorted, overlaps and touches merged, a full cover collapsing to the
empty selection — so two spellings of one selection cannot fork a source. The probe result is
kept whole rather than re-spelled field by field, because `metadata.fps` is the rate the file was
*shot* at and `extraction_fps` is the rate we chose to *cut* it at, and re-declaring the first
beside the second is how the two come to be confused.
Expand All @@ -74,7 +77,7 @@ over one file**, not one source with a history.

## Registration is idempotent

The match key is `(kind, path, extraction_fps)`. Registering the same origin twice returns the
The match key is `(kind, path, extraction_fps, ranges)`. Registering the same origin twice returns the
same `Source` rather than a second one, so that once ingest gives `asset.source_id` a target,
"which source did this asset come from?" has one answer.

Expand All @@ -98,12 +101,15 @@ because nothing referenced a source, so a duplicate was inert - and [ingest](ing
that, by giving `asset.source_id` a target and letting the winner of a race decide an asset's
recorded origin.

`uq_source_project_kind_path_fps` went in with it, over
`(project_id, kind, path, coalesce(json_extract(video, '$.extraction_fps'), 0))`. The fourth term
is an expression rather than a column, and it is `coalesce`d rather than left to be NULL, because
SQLite treats NULLs in a unique index as **distinct** - an image directory, whose `video` is NULL,
would otherwise never collide with itself, which is most of what the index is for. `0` cannot be
mistaken for a real rate: `extraction_fps` is `gt=0`.
`uq_source_project_kind_path_fps_ranges` went in with it — born four-term as
`uq_source_project_kind_path_fps`, reshaped by migration 16 when ranges joined the identity — over
`(project_id, kind, path, coalesce(json_extract(video, '$.extraction_fps'), 0),
coalesce(json_extract(video, '$.ranges'), ''))`. The last two terms are expressions rather than
columns, and they are `coalesce`d rather than left to be NULL, because SQLite treats NULLs in a
unique index as **distinct** - an image directory, whose `video` is NULL, would otherwise never
collide with itself, which is most of what the index is for. `0` cannot be mistaken for a real
rate (`extraction_fps` is `gt=0`), and an empty selection omits its JSON key when stored, so a
whole-clip row written in any generation lands on `''`.

The two layers do what they do everywhere else in this store. The pre-check is what produces a
friendly answer; the index is the guarantee. A caller that loses the race sees a raw
Expand All @@ -130,8 +136,9 @@ the workspace, so both stay outside the `VisionSetError` tree - the same line
## Over HTTP, a path is an upload

`SourceService` registers by path, and an HTTP client has bytes rather than a path. So the
[REST API](api.md) takes multipart - one `files` part per image, or one `file` part plus an
`extraction_fps` field for a clip - writes the parts under `<workspace>/uploads/`, and registers
[REST API](api.md) takes multipart - one `files` part per image, or one `file` part plus
`extraction_fps` and an optional `ranges` field for a clip - writes the parts under
`<workspace>/uploads/`, and registers
what it wrote. There is **no route that accepts a server-side path**: it would hand every token
holder an arbitrary-directory read, and the two surfaces that legitimately hold real paths, the
CLI and MCP, call the SDK in-process and never go through HTTP.
Expand Down
22 changes: 20 additions & 2 deletions docs/content/ui.md
Original file line number Diff line number Diff line change
Expand Up @@ -876,8 +876,26 @@ designing around it.
assets" only means something if the parameters are part of what the source *is* -
and the probe result exists only once the clip is registered. So the rate is chosen
first, the clip is registered, and then its native fps, duration, codec and
resolution are shown. Registering the same clip at another rate produces a
**second source**, deliberately: idempotency is on `(kind, path, extraction_fps)`.
resolution are shown. Registering the same clip at another rate — or over other
clip ranges — produces a **second source**, deliberately: idempotency is on
`(kind, path, extraction_fps, ranges)`.

A decodable clip gets an editor-shaped block in step 1: a compact preview
player, the cut's facts beside it — rate, frame count, selection — and a
hand-rolled multi-range timeline spanning the panel under both, its empty track
inviting the drag from inside itself. Selection is whole seconds: a drag
paints second cells, a range starts on an exact second and ends just before
its last one closes (the clip's partial final second is the one shorter
cell), and only the marker seconds are labelled under the track, as plain
numbers. The handles drag and nudge by one second (shift for ten), Delete
removes a range, and a click scrubs the player — inside a selected
range it previews, playing from that moment and stopping where the range ends. The selection rides
to registration as typed and the kernel canonicalizes; the probe card's `Ranges`
fact echoes the canonical form, which is where an overlapping selection is first
seen merged. The frame estimate is exact — the mirrored `ceil` arithmetic over
the merged selection, the same numbers the extraction filter is built from. A
clip the browser cannot decode gets no timeline and one line saying it will be
ingested whole.

Three more things it inherits:

Expand Down
1 change: 1 addition & 0 deletions frontend/app/e2e/gallery.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -385,6 +385,7 @@ async function serveApi(page: Page, sent: Request[], options: Options = {}): Pro
fps: 30,
width: 1280,
height: 720,
ranges: [],
},
} satisfies Wire["SourceOut"],
});
Expand Down
Loading
Loading