Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
79def57
Restructured Save/Upload output as a BIDS-Study tree (BEP047 sourceda…
claude Aug 20, 2026
029bf51
Moved .slp to derivatives, gave sourcedata's original video its own t…
claude Aug 20, 2026
3290f1a
Used date-/time- entities for a known subject's derivatives, and no d…
claude Aug 20, 2026
5e0e6f4
Moved sourcedata's original video under sourcedata/rawbids/, with its…
claude Aug 20, 2026
fc3439f
Trimmed the CHANGELOG entry for this PR down to two items
claude Aug 20, 2026
6c9cf26
Applied review suggestion: dropped the mock_sub CHANGELOG entry
claude Aug 20, 2026
384dc19
Named the root dataset_description.json as a BIDS study, and recorded…
claude Aug 20, 2026
a8f93ed
Dropped the empty Sources key on the source video's sidecar, reworded…
claude Aug 20, 2026
42705e6
Added video codec to sidecars, matched derivatives/sourcedata dataset…
claude Aug 21, 2026
8694557
Added a mock_ready live-test flag that skips manual selection/descrip…
claude Aug 21, 2026
8772a02
Credited signed-in accounts, simulated EMBER-sourced mocks, added Dat…
claude Aug 21, 2026
5edd913
Dropped SourceVideo from GeneratedBy, added sidecar Checksum and code…
claude Aug 21, 2026
51c583c
Removed mock_sub/mock_ses; remote_listing's fake videos are BIDS-shap…
claude Aug 21, 2026
9a7e41f
Named sourcedata videos per BEP047, mocked ready-to-save snippet/arch…
claude Aug 21, 2026
df0322f
Named the plain extracted clip desc-extracted+clip, alongside desc-ov…
claude Aug 21, 2026
6a50d2d
Dropped redundant per-sidecar GeneratedBy, and SourceDatasets for a l…
claude Aug 21, 2026
0694e13
Moved the per-delivery disambiguator off filenames and onto its own d…
claude Aug 21, 2026
409dfa5
Spelled out the live-test grid, and gave mock_ready a real selection
claude Aug 21, 2026
261cdcf
Dropped the bare mock_ready link from the live-test table
claude Aug 21, 2026
9b73103
Named the bundle desc-extracted+clip, and dropped the timestamp from …
claude Aug 21, 2026
660a22c
Dropped desc- from the derivative files, and put their recording- ent…
claude Aug 21, 2026
75e1e29
Collapsed this PR's changelog to one entry, and wrote down why
claude Aug 21, 2026
71a9290
Named the bundle after its dataset, dropping BIDS entities from it en…
claude Aug 21, 2026
4c8e329
Pointed SourceDatasets at the source dandiset, and moved the mock id …
claude Aug 22, 2026
9998835
Added the source asset's BlobID to SourceDatasets
claude Aug 22, 2026
7b5cae1
Added SHA-256 to every sidecar Checksum, streamed like the MD5 beside it
claude Aug 22, 2026
0a8c12e
Named the bundle after its SOURCE dandiset, not the upload destination
claude Aug 22, 2026
1344c01
Left test-injection links unrewritten and read the frame sidecar's pi…
claude Aug 23, 2026
a1c8251
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Aug 23, 2026
4cc54a1
Added golden expected_output trees locking in what a mock-link Save w…
claude Aug 23, 2026
a39a013
Dropped the companion sidecar for loaded .slp/.nwb pose files
claude Aug 23, 2026
11680e1
Added expected_output trees for the two snippet links, driving a real…
claude Aug 23, 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
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
# Changelog

## 1.1.0

#### 🚀 Enhancement

- Save and Upload now write a BIDS-Study-shaped tree instead of a single ad hoc folder: the source video mirrors the dandiset's own subject/session under `sourcedata/rawbids/` (its own valid raw BIDS dataset), and the extracted clip or frame, its pose overlay, and any `.slp` sit under `derivatives/clip-extractor/`, named per BEP047's entities with BEP028-style provenance in their sidecars and in `dataset_description.json` ([#41](https://github.com/brain-bbqs/clip-extractor/pull/41))
- Added `?test&mock_video&mock_sub=<label>[&mock_ses=<label>]` to preview the Save/Upload destination tree for a known archive subject and session ([#41](https://github.com/brain-bbqs/clip-extractor/pull/41))
Comment thread
CodyCBakerPhD marked this conversation as resolved.
Outdated

## 1.0.0

#### 🐛 Bug Fix
Expand Down
38 changes: 23 additions & 15 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,20 +2,22 @@

## Live test injections

| URL | Expected Appearance | Link |
| -------------------------------------- | -------------------------------------------------------- | ---------------------------------------------------------------------------------- |
| `?test&signed_out` | Page as seen by a signed-out visitor | [Open](https://clip-extractor.brain-bbqs.org/?test&signed_out) |
| `?test&num_datasets=0` | "Not added to any direct-upload datasets" | [Open](https://clip-extractor.brain-bbqs.org/?test&num_datasets=0) |
| `?test&num_datasets=1` | Single fake dataset, Upload enabled | [Open](https://clip-extractor.brain-bbqs.org/?test&num_datasets=1) |
| `?test&num_datasets=2` | Dropdown of 2 fake datasets | [Open](https://clip-extractor.brain-bbqs.org/?test&num_datasets=2) |
| `?test&num_datasets=1&embargoed=false` | Non-embargoed fake dataset: error card, Upload disabled | [Open](https://clip-extractor.brain-bbqs.org/?test&num_datasets=1&embargoed=false) |
| `?test&num_datasets=1&human_subjects` | Flagged dataset: warning banner, blur tool, gated Upload | [Open](https://clip-extractor.brain-bbqs.org/?test&num_datasets=1&human_subjects) |
| `?test&mock_video` | A synthesized 30-frame clip loaded, as if dropped | [Open](https://clip-extractor.brain-bbqs.org/?test&mock_video) |
| `?test&mock_video=200` | Same, 200 frames, enough to see the trim track | [Open](https://clip-extractor.brain-bbqs.org/?test&mock_video=200) |
| `?test&mock_video_long` | A 4-hour clip: the sliding-window timeline and width | [Open](https://clip-extractor.brain-bbqs.org/?test&mock_video_long) |
| `?test&mock_video&mock_slp` | Loaded clip with a synthesized, matching pose overlay | [Open](https://clip-extractor.brain-bbqs.org/?test&mock_video&mock_slp) |
| `?test&mock_video&mock_slp&mismatch` | Same, but refused: the SLEAP card's mismatch state | [Open](https://clip-extractor.brain-bbqs.org/?test&mock_video&mock_slp&mismatch) |
| `?test&remote_listing=12` | Browse pane, 12 fake videos across 3 fake datasets | [Open](https://clip-extractor.brain-bbqs.org/?test&remote_listing=12) |
| URL | Expected Appearance | Link |
| ------------------------------------------ | ---------------------------------------------------------- | -------------------------------------------------------------------------------------- |
| `?test&signed_out` | Page as seen by a signed-out visitor | [Open](https://clip-extractor.brain-bbqs.org/?test&signed_out) |
| `?test&num_datasets=0` | "Not added to any direct-upload datasets" | [Open](https://clip-extractor.brain-bbqs.org/?test&num_datasets=0) |
| `?test&num_datasets=1` | Single fake dataset, Upload enabled | [Open](https://clip-extractor.brain-bbqs.org/?test&num_datasets=1) |
| `?test&num_datasets=2` | Dropdown of 2 fake datasets | [Open](https://clip-extractor.brain-bbqs.org/?test&num_datasets=2) |
| `?test&num_datasets=1&embargoed=false` | Non-embargoed fake dataset: error card, Upload disabled | [Open](https://clip-extractor.brain-bbqs.org/?test&num_datasets=1&embargoed=false) |
| `?test&num_datasets=1&human_subjects` | Flagged dataset: warning banner, blur tool, gated Upload | [Open](https://clip-extractor.brain-bbqs.org/?test&num_datasets=1&human_subjects) |
| `?test&mock_video` | A synthesized 30-frame clip loaded, as if dropped | [Open](https://clip-extractor.brain-bbqs.org/?test&mock_video) |
| `?test&mock_video=200` | Same, 200 frames, enough to see the trim track | [Open](https://clip-extractor.brain-bbqs.org/?test&mock_video=200) |
| `?test&mock_video_long` | A 4-hour clip: the sliding-window timeline and width | [Open](https://clip-extractor.brain-bbqs.org/?test&mock_video_long) |
| `?test&mock_video&mock_slp` | Loaded clip with a synthesized, matching pose overlay | [Open](https://clip-extractor.brain-bbqs.org/?test&mock_video&mock_slp) |
| `?test&mock_video&mock_slp&mismatch` | Same, but refused: the SLEAP card's mismatch state | [Open](https://clip-extractor.brain-bbqs.org/?test&mock_video&mock_slp&mismatch) |
| `?test&remote_listing=12` | Browse pane, 12 fake videos across 3 fake datasets | [Open](https://clip-extractor.brain-bbqs.org/?test&remote_listing=12) |
| `?test&mock_video&mock_sub=01` | Save preview named `sub-01_recording-…`, not `sub-unknown` | [Open](https://clip-extractor.brain-bbqs.org/?test&mock_video&mock_sub=01) |
| `?test&mock_video&mock_sub=01&mock_ses=02` | Same, with a `ses-02` entity too | [Open](https://clip-extractor.brain-bbqs.org/?test&mock_video&mock_sub=01&mock_ses=02) |

**Safety**: `?test` alone, with none of the params below, is a no-op. Nothing here writes to real
`localStorage`, nothing touches real sign-in tokens, and every fake id is chosen from a range no real
Expand All @@ -39,7 +41,13 @@ fixture, and loads it exactly as if it had been dropped onto the picker. It is t
injection: the player, the timeline, the delivery panes and the SLEAP card are all only interesting
once a video is on screen. Add `mock_slp` to also synthesize a matching pose model over it, drawn
through the same overlay code a real `.slp` would be, or `mock_slp&mismatch` to make that pose
describe a different recording, previewing the SLEAP card's mismatch refusal.
describe a different recording, previewing the SLEAP card's mismatch refusal. Add `mock_sub` (and
optionally `mock_ses`) to make the mock video look, to the BEP047 path/filename builders (see
lib/bidsPath.ts), as if it were opened out of the archive at `sub-<mock_sub>/[ses-<mock_ses>/]…` —
previewing the Save/Upload destination tree for a known subject (and session) rather than only the
`sub-unknown` fallback a locally dropped file gets, which is itself worth trying plain: `mock_video`
alone is that fallback case. Save works fully offline, so the resulting `.tar.gz` can be downloaded
and unpacked to see the whole tree either way, without needing a real EMBER sign-in.

**A long recording**: `mock_video_long` (optionally `=N` for the duration in seconds, default 14400,
4 hours) previews the sliding-window timeline a recording past half an hour gets (see
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "clip-extractor",
"version": "1.0.0",
"version": "1.1.0",
"description": "A single-page video player for selecting a frame range and extracting it as an upload-ready payload, built on sleap-io.js and ffmpeg.wasm.",
"type": "module",
"license": "MIT",
Expand Down
170 changes: 170 additions & 0 deletions src/lib/bidsPath.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
// The BEP047 layout this app writes into: an extracted behavioral recording lands under
// sub-<label>/[ses-<label>/]beh/, once as a derivative (derivatives/clip-extractor/) and, when the
// source travels along too, once more under sourcedata/rawbids/ — see
// https://github.com/bids-standard/bids-specification/pull/2231 ("BEP047: Audio/video recordings
// for behavioral experiments") for the entity vocabulary (`sub`, `ses`, `recording`, and the
// `_audio`/`_video`/`_audiovideo`/`_image` suffixes) this module builds names out of.
//
// `sourcedata/` itself is not validated against BIDS naming at all — the spec explicitly leaves it
// free-form — but `rawbids/` underneath it is deliberately built as a complete, independently valid
// `DatasetType: "raw"` BIDS dataset of its own (its own `dataset_description.json`, entity-named
// files throughout), so that running a validator directly on `sourcedata/rawbids/` — not just on the
// dandiset as a whole — passes. That is the point of the name: the *raw*, *BIDS*-shaped copy of what
// this app read the selection out of, sitting in the place `sourcedata/` is for.
//
// BEP047 has no entity for "which delivery is this" — the closest fits (`run`, `split`) mean
// something else — so every derivatives filename this app writes carries a disambiguating entity of
// its own, which of two forms it takes depending on whether the subject is a real one:
//
// - A known subject (the source video's own path named one) means its `sourcedata` copy is the
// one true copy of that recording — re-delivering it is expected to overwrite, not duplicate, so
// `sourcedata` gets no disambiguator at all (see deliverOriginalVideo in main.ts, which always
// names it verbatim regardless). `derivatives`, though, still needs one: two runs of
// clip-extractor over the same subject/session produce two different clips, so those get
// `date-<label>_time-<label>` — separate entities, the same spelling the pre-BIDS directory
// layout used, now inside the filename instead of a directory name.
// - `sub-unknown` (the source names no subject at all — a video dropped straight from disk) means
// there is nothing else tying two such deliveries apart, in either tree, so both keep the
// compact `recording-<label>` stamp instead.
//
// `desc-<label>` — the entity BIDS derivatives already define for distinguishing outputs of the
// same underlying recording — separates the plain extracted clip from its pose-overlay rendering,
// the one thing BEP047's own vocabulary has no entity for.

import { sanitizeSegment } from "./sanitize";

/** Every asset this app writes into a dandiset's derivatives sits under this pipeline name. */
export const DERIVATIVES_PIPELINE = "clip-extractor";

/** A BIDS entity *label* is `[0-9a-zA-Z]+` — no punctuation at all, unlike an ordinary path
* segment (see lib/sanitize.ts's `sanitizeSegment`, which keeps `._+-`). Accents fold to their base
* letter first, so "café" reads as "cafe" rather than losing the character outright. */
export function bidsLabel(value: string, fallback: string): string {
const collapsed = value
.normalize("NFKD")
.replace(/[̀-ͯ]/g, "")
.replace(/[^A-Za-z0-9]+/g, "");
return collapsed || fallback;
}

export interface BehEntities {
sub: string;
ses: string | null;
/** Whether `sub` (and so `ses`) came from a real archive path, rather than the `sub-unknown`
* fallback. Decides which of `recording`/`date`+`time` `behFilename` uses to disambiguate this
* delivery from any other of the same subject/session — see the module comment above. */
known: boolean;
/** The compact digits-only instant stamp, used only when `!known`. */
recording: string;
/** This delivery's date, used only when `known` (paired with `time` in place of `recording`). */
date: string;
/** This delivery's time, used only when `known`. */
time: string;
}

/** Parses `sub-<label>[/ses-<label>]` off the front of an archive-relative path, e.g. `sub-1/mice.mp4`
* or `sub-01/ses-02/beh/whatever.mp4`. Both fields are null when the path does not start that way —
* a locally dropped file or an arbitrary streamed URL, neither of which the archive has a path for. */
export function parseSourceSubjectSession(sourcePath: string | null): { sub: string | null; ses: string | null } {
if (!sourcePath) return { sub: null, ses: null };
const segments = sourcePath.split("/");
const subMatch = /^sub-([A-Za-z0-9]+)$/.exec(segments[0] ?? "");
if (!subMatch) return { sub: null, ses: null };
const sesMatch = /^ses-([A-Za-z0-9]+)$/.exec(segments[1] ?? "");
return { sub: subMatch[1], ses: sesMatch ? sesMatch[1] : null };
}

/** A compact, sortable `recording-<label>` value: this delivery's own instant, digits only (no `-`,
* `:`, `.` or `Z`, none of which a BIDS label may hold). The exact instant, timezone designator
* included, is still recorded in the sidecar's `created_at`. */
export function recordingLabel(now: Date): string {
return now.toISOString().replace(/[^0-9]/g, "");
}

/** `YYYYMMDD`, UTC — `toISOString` is fixed-width, so this slice is stable. */
export function dateLabel(now: Date): string {
return now.toISOString().slice(0, 10).replace(/-/g, "");
}

/** `HHMMSS`, UTC. */
export function timeLabel(now: Date): string {
return now.toISOString().slice(11, 19).replace(/:/g, "");
}

/** The subject/session/delivery entities every file of one delivery shares, derived from wherever
* the source video's own path names a subject — falling back to `sub-unknown` so the tree this app
* writes is always well-formed even for a video dropped straight from disk. */
export function behEntities(now: Date, sourcePath: string | null): BehEntities {
const parsed = parseSourceSubjectSession(sourcePath);
return {
sub: parsed.sub ? bidsLabel(parsed.sub, "unknown") : "unknown",
ses: parsed.ses ? bidsLabel(parsed.ses, "") || null : null,
known: parsed.sub !== null,
recording: recordingLabel(now),
date: dateLabel(now),
time: timeLabel(now),
};
}

function subjectSessionSegments(e: BehEntities): string[] {
return e.ses ? [`sub-${e.sub}`, `ses-${e.ses}`] : [`sub-${e.sub}`];
}

/** Every asset this app writes into `sourcedata/` sits under this raw-BIDS-copy pipeline name. */
export const SOURCEDATA_RAWBIDS = "rawbids";

/** Where the original source content sits, mirroring the dataset's own subject/session layout —
* `sourcedata/rawbids/sub-<label>/[ses-<label>/]beh`. */
export function sourcedataDirectory(e: BehEntities): string {
return ["sourcedata", SOURCEDATA_RAWBIDS, ...subjectSessionSegments(e), "beh"].join("/");
}

/** Where this app's own output sits — `derivatives/clip-extractor/sub-<label>/[ses-<label>/]beh`. */
export function derivativesDirectory(e: BehEntities): string {
return ["derivatives", DERIVATIVES_PIPELINE, ...subjectSessionSegments(e), "beh"].join("/");
}

export interface BehFilenameParts {
/** BIDS derivatives' own entity for "which output, of the same recording, is this" — `overlay`
* for the pose-drawn rendering; omitted for the plain extracted clip. */
desc?: string;
/** What the file holds: BEP047's `video`/`audio`/`audiovideo`/`image`, or (for content BEP047
* has no suffix for, like a SLEAP `.slp`) a plain descriptive word — sourcedata is not validated
* as strictly as the rest of a BIDS tree. */
suffix: string;
ext: string;
}

function disambiguatorEntities(e: BehEntities): string[] {
return e.known ? [`date-${e.date}`, `time-${e.time}`] : [`recording-${e.recording}`];
}

/** `sub-<label>[_ses-<label>]_<date-<label>_time-<label>|recording-<label>>[_desc-<label>]_<suffix>.<ext>`
* — every file one delivery writes shares this prefix, so a listing of `beh/` reads as one group per
* delivery. */
export function behFilename(e: BehEntities, parts: BehFilenameParts): string {
const bits = [`sub-${e.sub}`];
if (e.ses) bits.push(`ses-${e.ses}`);
bits.push(...disambiguatorEntities(e));
if (parts.desc) bits.push(`desc-${parts.desc}`);
bits.push(parts.suffix);
return `${bits.join("_")}.${parts.ext}`;
}

/** The JSON sidecar for a BEP047 media file: same name, `.json` in place of the media extension. */
export function behSidecarName(e: BehEntities, parts: Omit<BehFilenameParts, "ext">): string {
return behFilename(e, { ...parts, ext: "json" });
}

/** Joins a directory and an already-legal filename into an asset path, sanitizing only the
* directory segments — the filenames above are built from BIDS labels already, so nothing in them
* needs to change. */
export function behAssetPath(directory: string, filename: string): string {
return directory
.split("/")
.map((s) => s.trim())
.filter((s) => s && s !== "." && s !== "..")
.map((s) => sanitizeSegment(s, "_"))
.concat(filename)
.join("/");
}
Loading
Loading