Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
8346c71
feat(kernel): scale percent and per-file image scales join the source…
JArmandoAnaya Aug 28, 2026
7665934
feat(kernel): migration 18 - scale terms join the source origin index
JArmandoAnaya Aug 28, 2026
97cdd4f
test(kernel): the uniqueness roster names the scale index
JArmandoAnaya Aug 28, 2026
e3afd6c
feat(kernel): registration takes a scale and forks identity on it
JArmandoAnaya Aug 28, 2026
df33e3c
feat(kernel): ffmpeg extraction scales frames to the stored size
JArmandoAnaya Aug 28, 2026
2f5fe32
feat(kernel): stills resize to their per-file scale before encoding
JArmandoAnaya Aug 28, 2026
0bfca7b
feat(server): registration accepts a scale and publishes it on the so…
JArmandoAnaya Aug 28, 2026
a26760e
feat(ui): probe dimensions, mirrored scale arithmetic, scale fields o…
JArmandoAnaya Aug 28, 2026
931f438
feat(ui): video ingest offers a scale slider with a stored-size preview
JArmandoAnaya Aug 28, 2026
ef82ae8
feat(cli,mcp): ingest takes --scale / scale for both source kinds
JArmandoAnaya Aug 28, 2026
bfe2192
feat(ui): image ingest shows a mosaic with per-file scale sliders
JArmandoAnaya Aug 28, 2026
2a235e9
docs: ingest-time downscale by percentage
JArmandoAnaya Aug 28, 2026
6f09e87
feat(ui): the scale block leads with the stored size, and the preview…
JArmandoAnaya Aug 28, 2026
7bd47bd
fix(ui): a mixed mosaic names its state instead of claiming captured …
JArmandoAnaya Aug 28, 2026
1eb4faa
fix(ui): mosaic thumbnails survive the StrictMode double-mount
JArmandoAnaya Aug 28, 2026
bf43d5f
refactor(ingest): scale is video-only - per-image resizing withdrawn
JArmandoAnaya Aug 28, 2026
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
14 changes: 8 additions & 6 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] [--range S:E]... [--batch-name NAME] [--start]
visionset ingest PATH --project P [--fps N] [--range S:E]... [--scale PCT] [--batch-name NAME] [--start]
visionset batch list --project P
visionset batch approve BATCH_ID [--jobs-of N] [--start]
visionset batch pre-label BATCH_ID CONNECTION [--minimum-confidence FLOAT] [--replace-model-labels] [--geometry SHAPE]...
Expand Down Expand Up @@ -347,16 +347,18 @@ name, no geometry, a select with no options - is refused there, named by its pos

### `visionset ingest`

`PATH --project P [--fps N] [--range S:E]... [--batch-name NAME]` - **the one command that is
two SDK calls**:
`PATH --project P [--fps N] [--range S:E]... [--scale PCT] [--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` 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
`--fps`, `--range` and `--scale` 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. `--scale` stores every extracted frame at that
percent of the clip's native size and, like the rate and the ranges, is part of the source's
identity: another scale is a second source. 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
4 changes: 2 additions & 2 deletions docs/content/ingest.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,8 +65,8 @@ reported as unsupported rather than skipped, because guessing which files an ope
offer is a policy the kernel would be inventing.

**Frames are not re-probed.** `VideoProcessor` guarantees every frame is a complete image in
`FRAME_FORMAT` at the dimensions `probe` reported, and that promise is asserted in the port's own
tests. Decoding each one again to re-confirm it would also route our own encoder's output into an
`FRAME_FORMAT` at the source's stored size — the probe's dimensions scaled by its
`scale_percent` — and that promise is asserted in the port's own tests. Decoding each one again to re-confirm it would also route our own encoder's output into an
operator's per-file report - a failure nobody could act on.

## Asking for a run and doing it are two calls
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`?, `ranges`?, `batch_name`? | Register a source and read it into one batch. Blocks until the run finishes. |
| `ingest` | `project`, `path`, `fps`?, `ranges`?, `scale`?, `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
12 changes: 6 additions & 6 deletions docs/content/persistence.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,14 +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_ranges`,
`uq_release_dataset_tag`, `uq_asset_project_content_hash`, `uq_source_project_kind_path_fps_ranges_scale`,
`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_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
`uq_source_project_kind_path_fps_ranges_scale` is one of the two whose terms are not all
columns: beside three column terms it compares `coalesce`d expressions over the `video` JSON
(`$.extraction_fps`, `$.ranges`, `$.scale_percent`). 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 @@ -286,8 +286,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_ranges` (its fourth and fifth terms are
`json_extract` expressions over `video`) and
category: `uq_source_project_kind_path_fps_ranges_scale` (its expression terms are
`json_extract`/`coalesce` 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
49 changes: 34 additions & 15 deletions docs/content/sources.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,8 +51,9 @@ 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 cut a decomposition will run at: `extraction_fps`, and the clip
`ranges` extraction reads, empty meaning the whole clip. Ranges are stored canonically —
dimensions, codec — plus the cut a decomposition will run at: `extraction_fps`, the clip
`ranges` extraction reads (empty meaning the whole clip), and `scale_percent`, the percent of
the native size frames are stored at (100 meaning unscaled). 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
Expand All @@ -72,12 +73,26 @@ assets. That promise only means something if the parameters are part of what "th
*is* - put them on the ingest job and two runs of one source could legitimately disagree, leaving
idempotency with nothing to be measured against.

The consequence is deliberate: **one clip registered at 1 fps and again at 5 fps is two sources
over one file**, not one source with a history.
The consequence is deliberate: **one clip registered at 1 fps and again at 5 fps — or at 100%
and again at 50% — is two sources over one file**, not one source with a history.

## Storing frames at a smaller size

A clip's registration takes an optional `scale_percent`, applied while ingest extracts frames:
the original upload is retained as staged, the *stored* frames are smaller, and every frame
shares one size because a clip has one native size. Each dimension becomes
`max(1, (native * percent + 50) // 100)`; the ingest screen mirrors that integer formula, so
the preview and the stored size cannot drift. Image directories always store stills at their
decoded size — an image batch mixes resolutions, and export is where a uniform size is made.

This is not the export-time resize: [pre-processing recipes](preprocessing.md) bring every
exported image to one model input size at export. The ingest-time scale exists to cut storage
and decode cost for clips nobody needs at native resolution, and it is permanent — the assets
*are* the smaller pixels. Re-ingesting the same clip at another scale is a second source.

## Registration is idempotent

The match key is `(kind, path, extraction_fps, ranges)`. Registering the same origin twice returns the
The match key is `(kind, path, extraction_fps, ranges, scale_percent)`. 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 @@ -101,15 +116,18 @@ 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_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 `''`.
`uq_source_project_kind_path_fps_ranges_scale` went in with it — born four-term as
`uq_source_project_kind_path_fps`, reshaped by migration 16 when ranges joined the identity and
by migration 18 when scale did — over `(project_id, kind, path,
coalesce(json_extract(video, '$.extraction_fps'), 0),
coalesce(json_extract(video, '$.ranges'), ''),
coalesce(json_extract(video, '$.scale_percent'), 0))`. The expression terms 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 or percent
(`extraction_fps` is `gt=0`, `scale_percent` is `ge=1`), and every default — an empty
selection, an unscaled clip — omits its JSON key, so a row written in any generation lands on
the same term.

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 Down Expand Up @@ -137,7 +155,8 @@ the workspace, so both stay outside the `VisionSetError` tree - the same line

`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
`extraction_fps` and an optional `ranges` field for a clip - writes the parts under
`extraction_fps`, an optional `ranges` field and an optional `scale_percent` 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
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 @@ -478,6 +478,7 @@ async function serveApi(page: Page, sent: Request[], options: Options = {}): Pro
width: 1280,
height: 720,
ranges: [],
scale_percent: 100,
},
} satisfies Wire["SourceOut"],
});
Expand Down
22 changes: 18 additions & 4 deletions frontend/ui-core/src/generated/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2702,10 +2702,11 @@ export interface paths {
* message says what was wrong with the file and never where it was put.
*
* The cut is part of what the source *is*: the same clip registered at 1 fps
* and again at 5 fps — or over different ranges — is two sources over one
* file, which is what makes "the same source yields the same assets" mean
* anything. Ranges are stored canonically (clamped, sorted, merged), and the
* response carries that canonical form.
* and again at 5 fps — or over different ranges, or at another scale — is two
* sources over one file, which is what makes "the same source yields the same
* assets" mean anything. Ranges are stored canonically (clamped, sorted,
* merged), and the response carries that canonical form. `scale_percent`
* below 100 stores every extracted frame at that percent of the clip's size.
*/
post: operations["register_video_source"];
delete?: never;
Expand Down Expand Up @@ -3709,6 +3710,12 @@ export interface components {
* @description Which stretches of the clip to extract, as a JSON array of {"start_seconds": s, "end_seconds": e} objects, each half-open [start, end). Omitted means the whole clip.
*/
ranges?: string | null;
/**
* Scale Percent
* @description Percent of the native size to store extracted frames at; 100 — the default — stores them unscaled. Part of the source's identity, like extraction_fps: the same clip at another scale is a second source.
* @default 100
*/
scale_percent: number;
};
/**
* BySegmentsBody
Expand Down Expand Up @@ -5801,6 +5808,11 @@ export interface components {
* `ranges` is the canonical form of the selection the source was registered
* with — clamped to the clip, sorted, overlaps merged — and empty means the
* whole clip. Like `extraction_fps`, it is part of the source's identity.
*
* `scale_percent` is the percent of the native size extracted frames are
* stored at; 100 means unscaled. `width` and `height` stay the clip's own —
* what is stored is each dimension scaled by this percent. Also part of the
* source's identity.
*/
VideoProvenanceOut: {
/** Codec */
Expand All @@ -5815,6 +5827,8 @@ export interface components {
height: number;
/** Ranges */
ranges: components["schemas"]["ClipRange"][];
/** Scale Percent */
scale_percent: number;
/** Width */
width: number;
};
Expand Down
2 changes: 1 addition & 1 deletion frontend/ui-core/src/generated/checks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -376,7 +376,7 @@ export const checkClipRange: Check<Schemas["ClipRange"]> =
/*#__PURE__*/ object({ "end_seconds": [true, isNumber], "start_seconds": [true, isNumber] } as const);

export const checkVideoProvenanceOut: Check<Schemas["VideoProvenanceOut"]> =
/*#__PURE__*/ object({ "codec": [true, isString], "duration_seconds": [true, isNumber], "extraction_fps": [true, isNumber], "fps": [true, isNumber], "height": [true, isInteger], "ranges": [true, arrayOf(checkClipRange)], "width": [true, isInteger] } as const);
/*#__PURE__*/ object({ "codec": [true, isString], "duration_seconds": [true, isNumber], "extraction_fps": [true, isNumber], "fps": [true, isNumber], "height": [true, isInteger], "ranges": [true, arrayOf(checkClipRange)], "scale_percent": [true, isInteger], "width": [true, isInteger] } as const);

export const checkSourceOut: Check<Schemas["SourceOut"]> =
/*#__PURE__*/ object({ "id": [true, isString], "kind": [true, checkSourceKind], "name": [true, isString], "project_id": [true, isString], "registered_at": [true, isString], "video": [true, either([checkVideoProvenanceOut, isNull] as const)] } as const);
Expand Down
6 changes: 5 additions & 1 deletion frontend/ui-core/src/screens/ClipRangeTimeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -352,8 +352,12 @@ export function ClipRangeTimeline({
ref={videoRef}
src={src}
controls
// Always mute: a vision dataset never needs the audio track, and
// the volume control it would earn is noise. The matching CSS in
// styles.css hides the control itself where the engine allows.
muted
preload="metadata"
className="max-h-84 w-full max-w-2xl shrink-0 rounded-lg bg-muted"
className="vs-muted-player max-h-84 w-full max-w-2xl shrink-0 rounded-lg bg-muted"
data-testid="clip-player"
onTimeUpdate={timeUpdated}
onPlay={playStarted}
Expand Down
Loading
Loading