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
15 changes: 15 additions & 0 deletions .ai/task-bids-inject.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,9 @@ Tracks implementation progress against [spec-bids-inject.md](spec-bids-inject.md
- [x] Media suffix determination (`_video` / `_audio` / `_audiovideo`) from `videos.tsv`
- [x] Delegate to `split-video` Python API (`do_main`)
- [x] Build `sidecar_metadata` dict from `record.metadata.TaskName` and pass to `do_main`
- [x] Populate `VideoCodecRFC6381` / `AudioCodecRFC6381` in `sidecar_metadata` via `get_audio_video_info_ffprobe` (TODO: future — read from `videos.tsv` columns instead)
- [x] Populate `BitDepth` / `PixelFormat` in `sidecar_metadata` from `VideoInfo.bit_depth` / `VideoInfo.pix_fmt`
- [x] ffprobe errors logged and injection continues (RFC6381 / BitDepth / PixelFormat silently omitted)

### Dry-run mode
- [x] Skip `split-video` call and file writes when `--dry-run`
Expand Down Expand Up @@ -210,6 +213,18 @@ Test file location: `tests/qr/test_bids_inject.py` (mirrors `tests/audio/test_au
- [x] `_is_scans_file` — `*_scans.tsv` existing file → `True`
- [x] `_is_scans_file` — directory or non-matching name → `False`

### sidecar_metadata propagation

- [x] `_call_split_video` passes `sidecar_metadata` with `TaskName` to `split-video`
- [x] `_call_split_video` passes empty `sidecar_metadata` when `TaskName` absent
- [x] `_call_split_video` adds `VideoCodecRFC6381` / `AudioCodecRFC6381` to `sidecar_metadata` via ffprobe
- [x] `_call_split_video` adds `BitDepth` / `PixelFormat` to `sidecar_metadata` via ffprobe
- [x] ffprobe failure logs error and injection continues; RFC6381/BitDepth/PixelFormat silently omitted
- [x] `_to_bids_model` uses `VideoCodecRFC6381` / `AudioCodecRFC6381` from `sidecar_metadata` when provided
- [x] `_to_bids_model` defaults `VideoCodecRFC6381` / `AudioCodecRFC6381` to `"n/a"` when absent
- [x] `_to_bids_model` writes `BitDepth` (int) / `PixelFormat` (str) from `sidecar_metadata` when present
- [x] `_to_bids_model` omits `BitDepth` / `PixelFormat` when absent from `sidecar_metadata`

### Integration tests (with synthetic BIDS fixture)

> Requires a small synthetic BIDS dataset fixture (a few `_scans.tsv` files, stub JSON
Expand Down
30 changes: 25 additions & 5 deletions .ai/task-video-audit.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,11 @@ Tracks implementation progress against [spec-video-audit.md](spec-video-audit.md
- [x] `VaContext` — Pydantic model carrying all processing options
- [x] `VaMode` — enum of operation modes
- [x] `VaSource` — enum of audit sources
- [x] `AudioInfo` — Pydantic model for audio stream info (codec, codec_long, codec_rfc6381, profile, sample_rate, channels, bits_per_sample, duration_sec, start_time, tag_str)
- [x] `VideoInfo` — Pydantic model for video stream info (codec, codec_long, codec_rfc6381, profile, level, width, height, pix_fmt, bit_depth, fps, duration_sec, start_time, tag_str)
- [x] `audio_codec_to_rfc6381` — RFC 6381 string for audio codec
- [x] `video_codec_to_rfc6381` — RFC 6381 string for video codec
- [x] `get_audio_video_info_ffprobe` — single ffprobe call returning `Tuple[AudioInfo, VideoInfo]`
- [x] `do_audit_file` — audit a single video file (INTERNAL)
- [x] `do_audit_dir` — audit all videos in a directory
- [x] `do_audit_internal` — entry point for INTERNAL source
Expand Down Expand Up @@ -128,12 +133,27 @@ Test file location: `tests/qr/test_video_audit.py`
- [x] `ffprobe` available (subprocess mock) → `True`
- [x] `ffprobe` not found (`FileNotFoundError` mock) → `False`

#### `get_audio_info_ffprobe`
- [x] Success with DURATION tag → codec/sample_rate/channels/bits_per_sample/duration set
#### `audio_codec_to_rfc6381`
- [x] AAC LC → `"mp4a.40.2"`
- [x] AAC HE-AAC → `"mp4a.40.5"`
- [x] AAC unknown/None profile → defaults to `"mp4a.40.2"`
- [x] MP3 → `"mp4a.69"`
- [x] Opus → `"opus"`
- [x] Unknown codec → `None`

#### `video_codec_to_rfc6381`
- [x] H.264 High@L4.2 → `"avc1.64002A"`
- [x] H.264 Baseline@L3.0 → `"avc1.42001E"`
- [x] H.264 None level → `"avc1.4D0000"`
- [x] Unknown codec → `None`

#### `get_audio_video_info_ffprobe` (replaces `get_audio_info_ffprobe`)
- [x] Success — audio fields: codec/codec_long/profile/sample_rate/channels/start_time/tag_str/codec_rfc6381/duration set
- [x] Success — video fields: codec/codec_long/profile/level/width/height/pix_fmt/bit_depth/fps/codec_rfc6381/duration/start_time/tag_str set
- [x] Duration read from stream `duration` field (no DURATION tag)
- [x] No audio streams → all fields `None`
- [x] `FileNotFoundError` → returns empty `AudioInfo`
- [x] `CalledProcessError` → returns empty `AudioInfo`
- [x] No audio streams → AudioInfo all `None`, VideoInfo populated
- [x] `FileNotFoundError` → returns empty AudioInfo and VideoInfo
- [x] `CalledProcessError` → returns empty AudioInfo and VideoInfo

#### `_compare_rec_ts`
- [x] Both `n/a` → `0`
Expand Down
20 changes: 19 additions & 1 deletion src/reprostim/qr/bids_inject.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,10 @@

from pydantic import BaseModel, Field

from reprostim.qr.video_audit import find_video_audit_by_timerange
from reprostim.qr.video_audit import (
find_video_audit_by_timerange,
get_audio_video_info_ffprobe,
)

# initialize the logger
logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -892,6 +895,21 @@ def _capturing_out_func(msg: str) -> None:
if record.metadata and record.metadata.TaskName:
sidecar_metadata["TaskName"] = record.metadata.TaskName

# TODO: in the future, read these fields from dedicated columns in videos.tsv
# populated by video-audit, avoiding the extra ffprobe call here.
try:
ai, vi = get_audio_video_info_ffprobe(input_path)
if vi.codec_rfc6381:
sidecar_metadata["VideoCodecRFC6381"] = vi.codec_rfc6381
if ai.codec_rfc6381:
sidecar_metadata["AudioCodecRFC6381"] = ai.codec_rfc6381
if vi.bit_depth is not None:
sidecar_metadata["BitDepth"] = vi.bit_depth
if vi.pix_fmt:
sidecar_metadata["PixelFormat"] = vi.pix_fmt
except Exception as e:
logger.error(f"Failed to get video info for {input_path}: {e}")

ret, split_results = split_video_main(
input_path=input_path,
output_path=output_path,
Expand Down
20 changes: 17 additions & 3 deletions src/reprostim/qr/split_video.py
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,9 @@ def _to_bids_model(sr: "SplitResult", sidecar_metadata: dict | None = None) -> d

:param sr: SplitResult to convert
:param sidecar_metadata: Optional dict with extra BIDS fields to inject.
Currently supports ``TaskName`` (written as the first field when present).
Supports ``TaskName`` (written as the first field when present),
``VideoCodecRFC6381``, ``AudioCodecRFC6381``, ``BitDepth``, and
``PixelFormat``.
:return: Dict with BIDS-compliant metadata field names
"""
result = {}
Expand All @@ -134,7 +136,9 @@ def _to_bids_model(sr: "SplitResult", sidecar_metadata: dict | None = None) -> d

if sr.video_codec != "n/a":
result["VideoCodec"] = sr.video_codec
result["VideoCodecRFC6381"] = "n/a"
result["VideoCodecRFC6381"] = (sidecar_metadata or {}).get(
"VideoCodecRFC6381", "n/a"
)

if sr.video_frame_rate is not None:
result["FrameRate"] = sr.video_frame_rate
Expand All @@ -151,9 +155,19 @@ def _to_bids_model(sr: "SplitResult", sidecar_metadata: dict | None = None) -> d
except (ValueError, TypeError):
pass

if sidecar_metadata:
bit_depth = sidecar_metadata.get("BitDepth")
if bit_depth is not None:
result["BitDepth"] = int(bit_depth)
pix_fmt = sidecar_metadata.get("PixelFormat")
if pix_fmt:
result["PixelFormat"] = pix_fmt

if sr.audio_codec != "n/a":
result["AudioCodec"] = sr.audio_codec
result["AudioCodecRFC6381"] = "n/a"
result["AudioCodecRFC6381"] = (sidecar_metadata or {}).get(
"AudioCodecRFC6381", "n/a"
)

if sr.audio_sample_rate != "n/a":
try:
Expand Down
Loading
Loading