Skip to content

Restructured Save/Upload output as a BIDS-Study tree - #41

Merged
CodyCBakerPhD merged 32 commits into
mainfrom
claude/bids-study-compliance-v1-p6qgn1
Aug 23, 2026
Merged

Restructured Save/Upload output as a BIDS-Study tree#41
CodyCBakerPhD merged 32 commits into
mainfrom
claude/bids-study-compliance-v1-p6qgn1

Conversation

@CodyCBakerPhD

Copy link
Copy Markdown
Member

What changed

Save and Upload wrote a single ad hoc, timestamped folder (sourcedata/raw/clip-extractor/date-…_time-…_type-…/). This PR replaces that with a real BIDS-Study tree:

  • sourcedata/ — the source video and any .slp, mirroring the dandiset's own sub-<label>/[ses-<label>/] structure (parsed off the source asset's own path when it was opened from the archive; falls back to sub-unknown for a locally dropped file).
  • derivatives/clip-extractor/ — the extracted clip/frame and its pose overlay, named per BEP047's sub-/ses-/recording-/desc- entities and _video/_image suffixes (recording-<label> is this delivery's own timestamp — BEP047 has no entity for "which delivery is this"; desc-overlay is the standard BIDS derivatives entity for a second output of the same recording).
  • Sidecars instead of a standalone provenance file — the extracted derivative's own _video.json/_image.json carries the description typed for the delivery, BEP047's technical keys, a BEP028-style GeneratedBy entry, and this app's full existing provenance record nested under its own clip-extractor key, so nothing from the old format is lost. The overlay gets its own lighter sidecar pointing back at the primary one.
  • dataset_description.json — an upload now folds a GeneratedBy entry (BEP028) into the dandiset's own root dataset_description.json and into derivatives/clip-extractor/dataset_description.json, fetching and merging into whichever already exists rather than overwriting it (creating either fresh only when nothing is registered yet).

A saved bundle holds the exact same tree an upload would have written, dataset_description.json files included, built fresh since there's no archive to merge against.

New modules

  • lib/bidsPath.ts — BEP047 entity parsing and path/filename builders
  • lib/generatedBy.ts — BEP028 GeneratedBy entries and dataset_description.json merging
  • lib/datasetDescription.ts — reads whatever dataset_description.json files already exist at the destination

Testing

  • All existing unit and Playwright integration specs updated for the new layout and passing, plus new unit tests for the three new modules and the sidecar builders in lib/provenance.ts.
  • npm run typecheck, npm run lint, npm test, npm run build, and the full Playwright suite all pass locally; pre-commit run --all-files is clean.
Original prompt

OK now last thing to polish before v1.1.0 deployment is to polish the output data structure to be completely BIDS-Study compliant (sourcedata BEP047 and dataset_description.json using provenance records from BEP028), description text and extracted snippet is then derivative, which should roughly follow same BEP046 structure but can have that text annotation in sidecar json instead of its own file

BEP bids-standard/bids-specification#2231
examples bids-standard/bids-examples#523


Generated by Claude Code

…ta/derivatives, BEP028 dataset_description.json provenance)

- sourcedata/ and derivatives/clip-extractor/ mirror the source video's own subject/session, with
  files named per BEP047's sub-/ses-/recording-/desc- entities and _video/_image suffixes
- the extracted clip or frame's own sidecar JSON now carries the delivery's description and this
  app's full provenance record (nested), replacing the separate provenance file
- an upload folds a BEP028-style GeneratedBy entry into the dandiset's dataset_description.json and
  into derivatives/clip-extractor/dataset_description.json, without disturbing anything already there

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NVfLrtMvcNB1qau7yTAva4
@github-actions

github-actions Bot commented Aug 20, 2026

Copy link
Copy Markdown
PR Preview Action v1.8.1
Preview removed because the pull request was closed.
2026-08-23 20:59 UTC

@codecov-commenter

codecov-commenter commented Aug 20, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 94.75806% with 13 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
src/lib/extract.ts 70.00% 6 Missing ⚠️
src/lib/testInjection.ts 82.60% 4 Missing ⚠️
src/lib/etag.ts 92.59% 2 Missing ⚠️
src/lib/streaming.ts 95.65% 1 Missing ⚠️

Impacted file tree graph

@@            Coverage Diff             @@
##             main      #41      +/-   ##
==========================================
+ Coverage   81.19%   84.90%   +3.70%     
==========================================
  Files          36       40       +4     
  Lines        1617     1802     +185     
  Branches      394      459      +65     
==========================================
+ Hits         1313     1530     +217     
+ Misses        304      272      -32     
Files with missing lines Coverage Δ
src/lib/archives.ts 96.00% <100.00%> (+0.20%) ⬆️
src/lib/bidsPath.ts 100.00% <100.00%> (ø)
src/lib/datasetDescription.ts 100.00% <100.00%> (ø)
src/lib/delivery.ts 100.00% <100.00%> (ø)
src/lib/generatedBy.ts 100.00% <100.00%> (ø)
src/lib/pngFormat.ts 100.00% <100.00%> (ø)
src/lib/provenance.ts 100.00% <100.00%> (ø)
src/lib/upload.ts 98.50% <100.00%> (+0.06%) ⬆️
src/lib/urlState.ts 92.85% <100.00%> (ø)
src/lib/streaming.ts 90.74% <95.65%> (+0.41%) ⬆️
... and 3 more
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@CodyCBakerPhD CodyCBakerPhD self-assigned this Aug 20, 2026
@CodyCBakerPhD
CodyCBakerPhD marked this pull request as ready for review August 20, 2026 02:21
claude added 4 commits August 20, 2026 02:46
…echnical sidecar, and added mock_sub/mock_ses test injections

- a loaded .slp now lands under derivatives/clip-extractor/ alongside the extract, since it is a pose
  estimation pipeline's output rather than raw recording content
- the source video's own sourcedata sidecar now carries its real duration/frame rate/dimensions,
  already read off it when it was loaded, rather than nothing at all
- ?test&mock_video&mock_sub=<label>[&mock_ses=<label>] previews the Save/Upload tree for a known
  archive subject and session, alongside the existing sub-unknown fallback

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NVfLrtMvcNB1qau7yTAva4
…isambiguator in sourcedata

A known subject/session (opened out of the archive rather than dropped locally) means sourcedata's
one copy of the source video is meant to be reused/overwritten across deliveries, not duplicated, so
it now carries no disambiguating entity at all; derivatives, still needing one per run, spells it as
separate date-/time- entities rather than the compact recording- stamp, which now applies only to the
sub-unknown fallback in both trees.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NVfLrtMvcNB1qau7yTAva4
… own dataset_description.json

sourcedata/ itself is not validated against BIDS naming at all, but sourcedata/rawbids/ is now built
as a complete, independently valid DatasetType: raw BIDS dataset (its own dataset_description.json,
entity-named files throughout), so a validator run directly against that subtree passes on its own,
not just as part of the dandiset as a whole.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NVfLrtMvcNB1qau7yTAva4
Comment thread CHANGELOG.md Outdated
claude added 20 commits August 20, 2026 23:20
… the source video in provenance

- the root dataset_description.json is now DatasetType: "study" (BIDS's own type for a root that
  organizes source, raw and derived data together, which is exactly what this app's tree is) and,
  when freshly created, named after the delivery that made it ("<Snippet|Frame> extracted using the
  Clip Extractor on <ISO datetime>") rather than just the dandiset id
- every dataset_description.json's GeneratedBy entry now carries as much as is known about the
  source video (filename, checksum, url, fps, dimensions, frame count), and two different videos at
  the same tool version get their own entries instead of being collapsed into one

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NVfLrtMvcNB1qau7yTAva4
… its description

Sources is now omitted entirely rather than written as [] when a companion file has no upstream to
name; the source video's own sidecar description reads 'The source video this selection was clipped
from.'

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NVfLrtMvcNB1qau7yTAva4
… names to the study, named the source in SourceDatasets

- BEP047 sidecars now carry VideoCodec when it is actually known: h264 for anything this app
  encoded with ffmpeg.wasm, the source's own codec for a streamed clip copied through untouched,
  and omitted rather than guessed for a re-encode mediabunny performed itself
- derivatives/clip-extractor/ and sourcedata/rawbids/'s own dataset_description.json now share the
  study root's Name verbatim, suffixed "(Extracted)"/"(Original)"
- the derivatives description's SourceDatasets now names the actual source video (URL when
  streamed, Filename/Checksum otherwise) instead of a placeholder "." URL

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NVfLrtMvcNB1qau7yTAva4
…tion

?test&mock_video alone lands on the gated 'describe it first' state,
which needed a frame picked and a description typed by hand before
Save/Upload output could actually be previewed. mock_ready does both
automatically, so the link alone lands on a saveable state.
…asetLinks

- dataset_description.json files now credit the signed-in archive
  account as a minimal Authors entry.
- mock_sub now also gives the mock video a fake asset URL, so it
  previews SourceDatasets the way a real EMBER-sourced clip would.
- The study root and the derivatives file cross-link each other via
  a DatasetLinks alias (clip / raw).
- GeneratedBy entries order their keys Name, Description, then the
  version-y fields.
…c/pixel-format detail

- GeneratedBy no longer carries a SourceVideo (not BEP028 vocabulary); SourceDatasets
  now accumulates one entry per distinct video across repeat deliveries instead.
- The study root's own dataset_description.json now also names sourcedata/rawbids/
  directly via a plain 'source' key.
- Video/image sidecars now carry a Checksum field (MD5 and dandi-etag, SPDX-shaped),
  and, where a StreamingVideoBackend can name them, VideoCodecRFC6381, ImagePixelFormat
  and ImageBitDepth; BEP047's own technical keys are now grouped together, last.
- A ?test&mock_video preview reports codec/pixel-format detail as h264's own typical
  values rather than leaking the mock's own VP8 encoding.
…ed instead

- ?test&mock_video&mock_sub=/&mock_ses= are gone. mock_video is always the
  'dropped locally' case now (sub-unknown fallback); fakeArchiveBrowse's fake
  video paths are zero-padded, proper BIDS entities (sub-01/ses-01/...,
  not sub-1/session-1) instead, previewing the dandiset-sourced shape.
- The study root's plain 'source' key is folded into its existing
  DatasetLinks object instead of sitting beside it.
- Minor: GeneratedBy's own Description text trimmed.
…ive cases, dropped ad hoc provenance block

sourcedata/rawbids's copy of the source video is now named
sub-<label>[_ses-<label>]_video.<ext> instead of keeping its original
filename, and a repeat delivery of the same subject/session overwrites it
instead of piling up duplicates.

?test&mock_video&mock_ready gained a =snippet variant and can be crossed
with &from_archive, previewing a video opened out of the archive (a known
sub-01/ses-02, with a real source URL) instead of dropped locally. The mock
video itself is now named/typed .mp4 to match what a real archive-sourced
recording would actually be, and from_archive's fixed mock path uses
sub-01/ses-02 to keep subject and session visibly distinct.

Dropped the ad hoc clip-extractor provenance block nested in the sidecar
JSON; a later PR will add a proper W3C PROV record in its place.
…erlay

The extracted video/frame previously carried no desc entity at all, unlike
its pose-overlay sibling's desc-overlay — BIDS labels may concatenate
multiple applicable ones with a plus sign, so the plain extract now reads
desc-extracted+clip: the thing this app extracted, and the clip it is
named for.
…ocal file

GeneratedBy is already recorded once in each dataset_description.json, so
repeating it in every sidecar was redundant; sidecars now carry only
Description/Checksum(/technical keys).

SourceDatasets previously named a locally dropped video by filename and
checksum alone when it had no real URL, but that pair only repeated what
the file's own sidecar already says and isn't what BIDS means by a source
dataset. A local delivery now leaves SourceDatasets out entirely; only a
video opened from a real address (streamed from the archive) gets an
entry.

Also extended the mock_ready/from_archive 2x2 Playwright coverage to
assert on that contrast directly, and fixed a couple of stale sub-01/ses-01
doc comments left over from the from_archive sub-01/ses-02 change.
…irectory

Every derivatives filename previously repeated date-/time- (or recording-,
for a locally dropped sub-unknown video) to tell one delivery apart from
another. That entity now names a directory instead:
derivatives/clip-extractor/sub-.../beh/<date-..._time-...|recording-.../>,
holding every file the delivery wrote. Filenames underneath keep only
sub-/ses-/desc-, so a listing of beh/ reads as one subdirectory per
delivery rather than one repeated stamp per file.

The Save bundle's own filename carries neither disambiguator: it is the
outer container, not a file in the tree it holds, so saving the same
source again names the same bundle rather than minting an ever-changing
name around the delivery's own instant.
The four ?test&mock_video&mock_ready links now name both halves of what
they preview rather than leaving each implicit:

  ?test&mock_video&mock_ready&from_local&frame
  ?test&mock_video&mock_ready&from_local&snippet
  ?test&mock_video&mock_ready&from_ember&frame
  ?test&mock_video&mock_ready&from_ember&snippet

&from_ember replaces &from_archive and &from_local spells out the default
it leaves alone; &frame/&snippet replace mock_ready=snippet, with &frame
likewise the explicit spelling of the default.

mock_ready also marks a selection somebody could plausibly have made
instead of one that only proves the gate opened: frame 12, or frames
6-21, of mock_video's own 30, held to the loaded video's own bounds so a
shorter mock_video=<n> still lands somewhere real. &frame=<n> and
&snippet=<lo>-<hi> name others. The frame case now moves the playhead
rather than only the marks, since frame mode extracts whatever state.cur
points at.
The four links below it spell the same grid out, and &from_local&frame is
that bare link exactly. Listing both invited pasting the one that leans on
defaults rather than the one that says what it previews.

The defaults themselves stay: &from_local and &frame remain the explicit
spelling of what mock_ready does on its own.
…dataset names

The saved bundle carried desc-frame/desc-snippet; it now carries the same
desc-extracted+clip as the extract inside it. The outer container says
nothing about which kind of selection it holds, so the same source saved
again names the same bundle either way.

The three dataset_description.json files no longer stamp their Name with
the delivery's instant. These files are dataset-level, not per-delivery:
the first delivery to find one missing creates it and every later one
folds into what is already there, so a name built around whichever moment
happened to be first would sit there permanently describing an instant
that means nothing to anything added after it. When a delivery happened
is still recorded where it belongs, in the date-/time- directory its own
derivatives sit in.

Also corrected generatedBy.ts's header, which still claimed the same
GeneratedBy entry is written into every sidecar.
…ity back

The plain extract is a delivery's primary output, so it needs no desc- to
set it apart from anything; only the pose overlay keeps one. Its files do
carry the delivery's own disambiguating entity again, alongside the
directory already named for it — the directory is what makes a listing of
beh/ read as one group per delivery, and the filename repeating it is what
keeps each file self-describing once it is copied or downloaded somewhere
that directory does not follow.

  derivatives/clip-extractor/sub-unknown/beh/recording-<label>/
    sub-unknown_recording-<label>_image.png
    sub-unknown_recording-<label>_image.json
    sub-unknown_recording-<label>_desc-overlay_image.png

The bundle keeps desc-extracted+clip as the one thing naming what the
container holds, and loses the _bundle suffix: it is a download, not a
file in a BIDS tree, so behFilename's sibling bundleFilename builds it
without a suffix or a disambiguator.

  sub-unknown_desc-extracted+clip.tar.gz

Also consolidated this PR's CHANGELOG entries, several of which described
steps later ones reversed rather than the net change.
Eleven entries had accumulated, one per follow-up request, several of
them narrating steps a later commit reversed rather than where the change
landed. They are now a single entry describing what someone using the app
would notice: Save and Upload write a BIDS-Study tree.

Added the corresponding guideline to CLAUDE.md, since the sprawl came
from adding an entry per commit rather than revising the PR's own.
…tirely

The saved bundle carried sub-/ses- and a desc-. None of that belonged on
it: the bundle is a download, not a file in the tree it holds, and it
stops existing the moment it is unpacked. A subject in its name also
claimed the container holds only that subject, which is not something it
promises.

It is now named for the dandiset it is destined for, or clip-extractor
when no dataset is picked:

  9900001.tar.gz
  clip-extractor.tar.gz

bundleFileName takes the dandiset id rather than BehEntities, so
bidsPath's bundle-specific builder and its optional suffix are gone
again.
…to 214000

SourceDatasets named the source *file* — its URL, filename and checksum —
which both overstated what BIDS means by the field and repeated what the
extract's own sidecar already carries. It now names the dataset the video
came out of, with the asset path within it as a detail:

  "SourceDatasets": [
    {
      "URL": "https://api-dandi.emberarchive.org/api/dandisets/214000",
      "Path": "sub-01/ses-02/test-injection-mock-video.mp4"
    }
  ]

The dandiset is carried on AppState as sourceDandisetId, set from the
browsed asset (ArchiveVideo already knew it) and by &from_ember for the
mock. A locally dropped file belongs to no dataset, so it still gets no
entry at all.

The fake dandiset id moves from 9900001 to 214000: six digits like a real
one, so it exercises the same parsing, and far enough up EMBER's
sequential range that nothing can collide with it.

ProvenanceInput collapses to the description alone, since the source
identity it used to carry now lives in SourceDatasets and in each file's
own Checksum.
DANDI stores an asset's bytes at a bucket URL ending in the blob's own id
(.../blobs/<x>/<y>/<uuid>), so the id the archive content-addresses the
source by is derivable from the listing this app already reads. It is held
to a UUID, so a URL shaped some other way yields nothing rather than
whatever its last segment happened to be, and an embargoed asset — whose
bucket URL is signed on demand rather than listed — simply gets no BlobID.

The three loose source fields threaded through loadVideo collapse into one
ArchiveSource ({dandisetId, path, blobId}), which is also what a delivery
records; a locally dropped file has none of it and still gets no entry.

The mock's own source deliberately carries no BlobID: that video is
synthesized in the page and was never stored as a blob, and inventing an
id would make it the one part of the preview that is not a truthful stand-
in for the real thing.
claude added 2 commits August 22, 2026 12:23
Each file's Checksum now leads with SHA-256, the digest most tooling
outside the archive reaches for, ahead of the MD5 and the dandi-etag it
already carried.

This needed hash-wasm: Web Crypto's crypto.subtle.digest has no
incremental API, so it would have to buffer a whole file to hash it, and
the MD5 here deliberately streams in 16MB reads precisely so a multi-GB
source video never lands in memory whole. hash-wasm's hashers take the
bytes a chunk at a time, so SHA-256 runs as a third pass in the same
shape, and checksum progress is now reported in thirds rather than halves.

The new digest is verified against node's own crypto in etag.test.ts,
including across the chunk boundary where the streaming loop actually
runs, and for the empty blob it never enters for.
bundleFileName read currentConfig().dandisetId — the dataset picked to
upload TO — so a save while signed in was named after wherever the next
upload would go (e.g. 000475), which says nothing about what is inside.
It now reads state.sourceArchive: the dandiset the video came OUT of, or
local-dataset for a video that came from no dataset at all.

  200123.tar.gz        (from_ember: the mock source dandiset)
  local-dataset.tar.gz (from_local: a locally dropped file)

The from_ember mock source is its own 200123 rather than one of the
214000+ fake destination datasets, so a preview keeps its source and its
destination visibly distinct, the way a real extraction's usually are.
SourceDatasets follows to .../dandisets/200123 in the mock.
claude and others added 5 commits August 23, 2026 16:36
…xel format off the PNG itself

The URL-state reflector was stripping a mock link's own flags (frame, mode
collide with session params) and respelling every bare &flag as &flag=;
under a test injection the harness owns the address, so nothing rewrites it
now, and carried-through bare flags keep their spelling elsewhere too.

Frame-mode sidecars (the extract's and the overlay's) now carry BEP047's
ImagePixelFormat/ImageBitDepth, read off the IHDR header of the PNG the
browser actually encoded rather than assumed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NVfLrtMvcNB1qau7yTAva4
…rites

Mirrors the expected_output fixture style of data-ingest-task-force's
labs/kemere/tests: each &frame live-test link is driven to a real save, the
bundle unpacked, its file listing held against a committed manifest and
every JSON file held in full against a committed copy. Run-varying values
(delivery timestamps, checksums of MediaRecorder-made bytes, the app
version) are normalized to spelled-out placeholders; regenerate with
UPDATE_EXPECTED=1. listTar moved to helpers.ts for the new spec to share.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NVfLrtMvcNB1qau7yTAva4
Both formats describe themselves from the inside — skeleton, frame indices
and provenance all live in the file — so a companion .json beside them only
restated what opening the file says. The pose file still travels with the
delivery and is still checksummed; BEP047 media assets keep their sidecars,
where the technical keys are defined to live. CompanionSidecarInput's
optional technical and nullable checksum existed only for the .slp, so both
are now required.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NVfLrtMvcNB1qau7yTAva4
… encode

The snippet cases were left out because a snippet extraction goes through
ffmpeg.wasm, whose ~32MB core the app fetches from a CDN. Serving that core
from a new @ffmpeg/core devDependency instead keeps the encode the app's own
while needing no network, so all four live-test links are now fixtured. The
extract's own VideoFrameCount is held exactly (16 frames, 6 through 21);
only the synthesized source's recorder-decided values stay normalized.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NVfLrtMvcNB1qau7yTAva4
@CodyCBakerPhD
CodyCBakerPhD merged commit e8ad26d into main Aug 23, 2026
15 checks passed
@CodyCBakerPhD
CodyCBakerPhD deleted the claude/bids-study-compliance-v1-p6qgn1 branch August 23, 2026 20:59
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants