Skip to main content

Upload Validation Pipeline

Upload validation protects data consistency and payment accuracy. A session should upload only after local artifacts prove that the recording is coherent.

The current implementation lives in RecordingArtifactValidator and is called by RecordingSessionFinalizer before files are registered for upload.

Required artifacts

Pure-v2 sessions require this local triplet:

ArtifactCurrent path rule
MP4Completed recording path, for example <base>.mp4.
CSVSame folder and base name as MP4, with _input.csv suffix.
JSONSame folder and base name as MP4, with _meta.json suffix.

Audio is muxed into the MP4 as AAC. There is no separate audio sidecar — the legacy _audio.ogg artifact was removed in EC-173 (commit af48e44, 2026-05-06) and the finalizer no longer references it.

Current validation config

These are the default values in code today:

SettingCurrent valueEffect
minVideoSizeBytes1,048,576 bytesMP4 files below 1 MiB are blocked as mp4_too_small. This value is sourced from UploaderConfig when available.
minVideoDurationSeconds1.0 secondMP4 duration below 1 second is blocked as mp4_too_short.
targetMuxedFps60.0 FPSProduct target used for FPS validation diagnostics.
minMuxedFps60.0 FPSNominal minimum.
minMuxedFpsAbsoluteTolerance2.0 FPSAllows a 2 FPS tolerance from target.
minMuxedFpsTargetRatio0.95Allows a 95% target-ratio floor.
Effective muxed FPS floor58.0 FPSComputed as min(60, max(60 - 2, 60 * 0.95)).
durationToleranceSeconds5.0 secondsMinimum duration mismatch tolerance.
durationToleranceRatio0.15Relative duration mismatch tolerance.
Effective duration tolerancemax(5s, reference_duration * 0.15)reference_duration is the larger absolute duration being compared.

Validation order

The validator short-circuits on the first blocking failure:

  1. Validate MP4 existence, regular-file status, size, frame counters, readable duration, and minimum duration.
  2. Validate CSV existence, header, parseability, timestamp monotonicity, JSON event_args, lifecycle boundaries, and positive effective duration.
  3. Validate JSON existence and parseability. If duration or muxed-frame fields are present, validate those fields.
  4. Validate muxed FPS from JSON metadata when both duration_s and frame_count_muxed are available.
  5. Compare CSV duration with MP4 duration.
  6. Compare JSON duration with MP4 duration when JSON duration is available.
  7. Return uploadable with diagnostics mp4_csv_json_valid.

Only after this succeeds does RecordingSessionFinalizer register the three local files in SQLite as mp4, csv, and json.

MP4 checks

The MP4 gate currently validates:

  • Path exists and is a regular file.
  • File size is at least 1,048,576 bytes.
  • Final capture stats are not both zero: framesCaptured == 0 && framesEncoded == 0 blocks as mp4_no_frames.
  • MP4 duration is readable from mvhd; if needed, fragmented MP4 duration is reconstructed from moof/trun data.
  • moov and moof payloads over 64 MiB are rejected while reading duration.
  • Duration must be at least 1.0 second.

CSV checks

The CSV contract is exact:

frame_number,timestamp,event_type,event_args

Rows must have exactly four fields. timestamp must parse as a finite number and must be monotonic; equal timestamps are allowed, decreasing timestamps are blocked. event_args must parse as JSON when present.

Lifecycle duration uses VIDEO_START and VIDEO_END when both exist. If those are missing, the validator falls back to START and END. VIDEO_PAUSE and VIDEO_RESUME intervals between VIDEO_START and VIDEO_END are subtracted from CSV duration. If the CSV is paused when VIDEO_END arrives, the final paused interval is subtracted up to VIDEO_END.

The resulting CSV duration must be positive.

JSON checks

The JSON file is mandatory and must parse. Current field behavior is:

FieldCurrent behavior
frame_count_muxedOptional, but if present it must be an integer greater than zero. Otherwise the result is mp4_no_frames.
duration_sOptional, but if present it must be finite and greater than zero. Otherwise the result is json_duration_mismatch.
start_unix_s + end_unix_sUsed as duration fallback when duration_s is absent. end_unix_s must be greater than start_unix_s.

Muxed FPS validation only runs when JSON has a valid duration and frame_count_muxed > 0. The computed value is:

muxed_fps = frame_count_muxed / duration_s

Since EC-360 this check is observe-only: when muxed_fps < 58.0 the condition is appended to the validation diagnostics as mp4_fps_below_threshold (observe-only) and surfaces in the recording.validation telemetry, but the session remains uploadable. (Until 4.1.10 it blocked the upload, and EC-346 then deleted the footage; field feedback reversed that.) The honest real-vs-CFR frame split is recorded in the _meta.json fps_breakdown block, so low-quality sessions are filtered downstream instead of being destroyed on the device.

Uploader readiness checks

After validation and SQLite registration, the uploader still checks file readiness before transfer:

CheckCurrent value
Exclusive file open succeedsRequired
File stability age2 seconds since last write
MP4 minimum size1,048,576 bytes
Max retries3
Bandwidth limit0 means unlimited
Pause while recordingtrue

These uploader checks are not a substitute for the triplet validator. They only protect transfer readiness.

Current failure reasons

These are the actual reason values returned by the validator today:

ReasonMeaning
mp4_missingMP4 path is missing, not a regular file, or cannot be statted.
mp4_too_smallMP4 is smaller than minVideoSizeBytes, currently 1 MiB.
mp4_no_framesFinal capture and encoded counters are zero, or JSON frame_count_muxed <= 0.
mp4_duration_unreadableMP4 duration cannot be read from mvhd or fragmented duration data.
mp4_too_shortMP4 duration is below 1.0 second.
csv_missingCSV path is missing or not a regular file.
csv_parse_errorCSV cannot be opened, is empty, has the wrong header, has malformed rows, invalid timestamps, or invalid event_args JSON.
csv_non_monotonic_timestampsA CSV timestamp is lower than the previous row timestamp.
csv_missing_startNeither VIDEO_START nor fallback START exists.
csv_missing_endNeither VIDEO_END nor fallback END exists.
csv_duration_mismatchCSV duration is non-positive or differs from MP4 beyond max(5s, 15%).
json_missingJSON metadata path is missing or not a regular file.
json_parse_errorJSON cannot be opened or parsed.
json_duration_mismatchJSON duration fields are invalid or differ from MP4 beyond max(5s, 15%).

mp4_fps_below_threshold is no longer a block reason: since EC-360 a JSON-derived muxed FPS below the 58.0 floor is recorded in the diagnostics of an otherwise-uploadable result (see "JSON checks" above).

Optional-automatic upload — the held status (EC-286)

By default a finished recording's artifacts are registered as pending and the global FIFO uploader pump (getNextPendingFile, WHERE status='pending') auto-uploads them. EC-286 adds an opt-out so the user can review artifacts — especially the transcript — before they leave the device:

  • A Settings toggle "Upload as soon as recording finishes" persists the auto_upload_enabled app setting (absent ⇒ treated as true, so existing installs are unaffected).
  • When the toggle is off, RecordingSessionFinalizer registers each artifact with status held instead of pending. Because the pump only claims pending, held files are excluded from upload by construction — the uploader itself is unchanged.
  • Transcription also becomes on-demand in this mode: the recording-stop observer skips its auto-enqueue, and the user triggers transcription per session (POST /api/transcribe/run).
  • Releasing a held recording flips held → pending and wakes the pump (POST /api/uploader/release for one session, or all = "Upload all").
  • Review happens in the in-app transcript editor (EC-384, playroll-ui): the Activity row's pencil opens a modal that renders the transcript as ordered sentences with timestamps (never raw JSON), with inline editing, Ctrl+F find and Ctrl+R replace. Saving rewrites only the per-segment text fields, regenerates the aggregate text (same join rule as the cpp build_transcript_document()), and writes atomically, so the upload pump ships the edited file when the user releases it. Files the editor can't parse fall back to the EC-286 alpha flow: open with the OS default app.

The held status is terminal-until-released: validation still ran at finalize time, so a released file is already known-consistent.

Important invariant

Partial uploads are not acceptable for new sessions. If validation returns blocked, no MP4, CSV, or JSON file should be registered for upload.

For a valid pure-v2 session, server-side upload records should match the required artifact count: three upload records per uploadable session.

If validation fails, the session is marked as failed with the granular block reason. If the failure is mp4_no_frames, or final stats show no frames, the finalizer discards the local MP4, CSV, and JSON metadata. That is the only deletion case: every other blocked artifact stays on disk for inspection and recovery. (EC-346 / 4.1.10 briefly extended deletion to the low-fps / under-delivered family — mp4_fps_below_threshold, csv_duration_mismatch, mp4_too_short; EC-360 reverted that after field feedback.)