Skip to content

Enforce the upload contract before a video leaves the device - #142

Open
jlocala1 wants to merge 2 commits into
mieweb:mainfrom
jlocala1:fix/upload-contract
Open

Enforce the upload contract before a video leaves the device#142
jlocala1 wants to merge 2 commits into
mieweb:mainfrom
jlocala1:fix/upload-contract

Conversation

@jlocala1

@jlocala1 jlocala1 commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

Uploads reached the server as 4K HEVC at ~28 Mbps with the index at the end of the file, while the recorder's own config asked for 1080p at 5 Mbps. Phones could not play them back.

This adds an upload-side gate: probe every video before it is uploaded, and re-encode only if it breaches the contract. It is deliberately complementary to the source-level fixes #143 rather than a replacement for them — see "how this relates to the H.264 work" below.

What was measured

Real clips from the same phone, profiled with ffprobe:

resolution bitrate codec faststart container
pre-07-21 3840x2160 28.80 Mbps hevc no qt
2026-07-29 1920x1080 8.24 Mbps hevc no qt

The resolution breach is fixed and not by anything here — targetResolution is not a setting but a bias in a weighted format vote, <Camera> ranks its preview output above ours, the preview wants a format at least as large as the screen, and every modern iPhone screen is taller than 1920px. A 1080p format therefore took an aspect-ratio penalty weighted 100x and 4K won by roughly 20x. VisionCamera 5.2.0 rescored that comparison; confirmed on a device.

What remains on the 07-29 build is why this PR exists: 8.24 Mbps against a 5 Mbps pin that has been set since 07-20, and no faststart.

What this changes

  • upload-contract.ts — the policy, pure and unit-tested: H.264, long edge <= 1920, <= 5 Mbps, AAC. 18 tests, including fixtures taken from the two real recordings above and the portrait case.
  • ensure-upload-contract.ts — the runtime. Probe, decide, re-encode only on breach. A compliant file costs one probe and is uploaded untouched.
  • upload-manager.ts — applied on both upload units. uploadUnit is chosen by the destination server, so gating only the merged path would silently disable the contract for any server that asks for segments.
  • use-recorder.ts / recorder.tsx — log the negotiated capture format and warn when it breaches, so a lost vote cannot go unnoticed for weeks again; and name the video output's resolutionBias explicitly. That is margin, not a fix: 5.2.0 wins by ~13% on the largest screens, an explicit bias makes it ~6x.
  • Import normalization failures are no longer swallowed. Falling back to the original bytes is right; being silent about it is how a 4K master enters a draft looking like one that was normalized.

Conditioned segment copies go to a stable path under the draft dir rather than a cache temp name: segment uploads resume byte-wise via TUS HEAD + PATCH, so a resumed run must send the bytes it began with. Re-encoding on resume would splice a second encode into a half-finished transfer.

How this relates to the H.264 work in #143

No overlap in code — this rebases onto it cleanly. Forcing H.264 at the recorder is the better fix for the codec breach and this PR does not duplicate it. The gate covers what the source misses today (bitrate, faststart) and, more durably, catches the case where a setting silently stops being honoured — which is exactly what happened with targetResolution. If the recorder becomes fully compliant, this gate becomes a no-op probe. That is the intended end state, not a wasted path.

Two things found while measuring, worth checking independently of this PR:

  1. The faststart patch in patches/react-native-vision-camera+5.2.0.patch sets shouldOptimizeForNetworkUse on HybridFrameRecorder. With enablePersistentRecorder unset, HybridCameraVideoOutput.createRecorder builds HybridVideoRecorder, which wraps AVCaptureMovieFileOutput — no faststart API at all. HybridFrameRecorder is only instantiated by HybridCameraVideoFrameOutput. Recordings still measure moov at 99.9%.
  2. targetBitRate: 5_000_000 still is not landing: 8.24 Mbps at 1080p, which is close to the default rate scaled by the pixel drop (28.8 / 4 = 7.2).

Verification

  • tsc --noEmit clean, 140 tests pass, expo lint 0 errors.
  • The decision logic is covered by unit tests against real measured probe values.
  • Not verified on a device. I have been removed from the PulseCam Dev TestFlight group, so I cannot install a build carrying this. That is the main thing missing before it should merge, and I would rather say so than imply otherwise.

Draft while that is outstanding. Happy to drop any part of this if you would rather close the remaining breaches at the source.

morepriyam added a commit that referenced this pull request Aug 4, 2026
- Recorder: force codec h264 via setOutputSettings once per video-output
  instance (re-applied after the enableAudio rebuild reverts it), gated on
  cameraReady && !isRecording so a live session is never mutated. iOS-only;
  Android CameraX is already AVC. HEVC recordings never decoded in Firefox
  and often not in Chrome.
- Import normalization: drop hevc from NATIVE_VIDEO_CODECS and pin
  codec:'h264' on the re-encode, so HEVC imports (all iPhone Photos videos)
  are conformed once at import time instead of leaking into merged output.
  Recordings and normalized imports now share one signature, widening the
  merge engine's zero-re-encode fast path.
- Bump react-native-video-trim to the fork commit adding +faststart to all
  merge/trim/compress outputs, and the pulsevault submodule to the
  web-ready backstop.

Known limits, documented in-code (verified against VisionCamera source,
measurements from #142): AVCaptureMovieFileOutput has no faststart API, so
raw per-clip files stay moov-at-end — faststart is owned by the merge
layer, the upload contract gate (#142), and the server backstop. The 5 Mbps
targetBitRate is applied inside the session-configuration batch and can
silently fail to land (~8 Mbps measured), so nothing downstream assumes it.

Fixes #140
@morepriyam

Copy link
Copy Markdown
Collaborator

Both findings check out against the VisionCamera 5.2.0 source — thank you for measuring this properly.

  1. The faststart patch was dead code, confirmed. HybridCameraVideoOutput.createRecorder returns HybridVideoRecorder, which wraps AVCaptureMovieFileOutput — no shouldOptimizeForNetworkUse (or any faststart) API exists on it. The patched HybridFrameRecorder is only instantiated by HybridCameraVideoFrameOutput, which this app doesn't record through. The patch is removed from feat: standardize pipeline on H.264 for web playback #143 and the in-code comments now state that raw per-clip files remain moov-at-end.
  2. The bitrate-not-landing mechanism is visible in the source. HybridCameraVideoOutput.configure silently returns when output.connection(with: .video) is nil, and setBitRate bails on the supportedOutputSettingsKeys guard with only a log — and both run inside the beginConfiguration/commitConfiguration batch before the session is live. Your 8.24 Mbps ≈ default-scaled-by-pixels reading is consistent with the settings never being applied. Also relevant: VideoOutputSettings exposes only codec, so there is no JS-side path to re-apply the bitrate post-ready the way feat: standardize pipeline on H.264 for web playback #143 does for the codec.

Net: #143 keeps the codec fix at the source (its setOutputSettings runs post-cameraReady, when the connection exists) and drops every claim it can't back; this gate is the right owner of bitrate + faststart enforcement for raw clips, with mieweb/pulsevault#58 as the server-side backstop for anything that slips past both. The three compose cleanly.

Re device verification: let's get your TestFlight access restored so this can come out of draft.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds an upload-side enforcement gate to ensure every video leaving the device meets a strict playback/upload contract (H.264, <=1920 long edge, <=5 Mbps target, AAC), complementing recorder-side configuration by probing and conditionally re-encoding only when a clip breaches the contract.

Changes:

  • Introduces a pure, unit-tested upload contract policy and a runtime “probe → decide → (maybe) transcode” gate, applied to both merged and segmented upload paths.
  • Tightens/standardizes import normalization to guarantee H.264 across the pipeline and improves e2e expectations for audio-less sources.
  • Improves operational robustness: stable per-draft conditioned segment output paths for resumable uploads; adds a VisionCamera iOS patch via patch-package and logs negotiated capture format + import-normalization failures.

Reviewed changes

Copilot reviewed 13 out of 14 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
src/utils/upload-contract.ts Defines the upload contract decision policy and bitrate derivation logic.
src/utils/upload-contract.test.ts Unit tests covering decision logic, including “real upload” probes.
src/utils/ensure-upload-contract.ts Implements the runtime enforcement (probe + conditional transcode) and cached/stable output for segments.
src/utils/file-store.ts Adds stable drafts/{draftId}/upload/ destination for conditioned segment copies.
src/features/upload/upload-manager.ts Applies the contract gate to both merged and segmented upload units and persists conditioned merged paths.
src/utils/import-normalization.ts Exports shared helpers/constants; changes import normalization to allow passthrough only for H.264; explicitly sets codec on re-encode.
src/utils/import-normalization.test.ts Updates fixture expectations reflecting HEVC import normalization.
src/utils/import-pipeline.e2e.test.ts Updates e2e expectations (HEVC fixtures now re-encode; audio-less outputs remain audio-less).
src/features/recorder/use-recorder.ts Logs negotiated capture resolution; forces H.264 on iOS video output; makes import-normalization failures visible.
src/app/recorder.tsx Adds explicit resolutionBias ordering to strengthen 1080p negotiation.
patches/react-native-vision-camera+5.2.0.patch Patch-package patch to enable shouldOptimizeForNetworkUse for the frame recorder writer.
package.json Adds patch-package and runs it on postinstall; bumps react-native-video-trim git ref.
package-lock.json Lockfile updates for patch-package and updated react-native-video-trim ref.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/utils/ensure-upload-contract.ts
@morepriyam

morepriyam commented Aug 5, 2026

Copy link
Copy Markdown
Collaborator

Working on the tests for these changes now, and verifying the whole thing works end to end — device runs for the capture/upload paths (codec, bitrate, moov placement) plus browser playback checks, across #143, #142, and the pulsevault backstop. Will report results here.

@morepriyam
morepriyam marked this pull request as ready for review August 5, 2026 00:09
…vice

Recordings reached the server at 3840x2160 / ~28 Mbps HEVC with the index at the
end of the file, while the recorder's own config asked for 1080p / 5 Mbps. Phones
could not play them back.

Measured on real clips from two builds, same phone:

             resolution      bitrate    codec  faststart  container
  pre-07-21  3840x2160    28.80 Mbps     hevc         no         qt
  2026-07-29 1920x1080     8.24 Mbps     hevc         no         qt

The resolution breach is already fixed upstream. `targetResolution` is not a
setting but a bias in a weighted vote: VisionCamera scores every camera format
against all outputs, and <Camera> ranks its preview output above ours. The
preview wants a format at least as large as the screen, every modern iPhone
screen is taller than 1920px, and a 1080p format therefore took an aspect-ratio
penalty weighted 100x. 4K won by roughly 20x. VisionCamera 5.2.0 rescored that
comparison and 1080p now wins on its own -- confirmed on a device, above.

So this does three things, in increasing order of how much they can be trusted:

- Name the video output's resolutionBias explicitly, first in the constraint
  list. This is no longer the fix, it is margin: 5.2.0 wins by ~13% on the
  largest screens, and an explicit bias makes it ~6x.
- Log the negotiated capture format on session start and warn when it breaches,
  so a lost vote can never again go unnoticed for weeks.
- Gate uploads on the contract itself: probe, and re-encode only on a breach. A
  compliant file costs one probe and is uploaded untouched.

The gate is the part that matters, and the 07-29 numbers are why: resolution is
fixed, but bitrate is still 65% over a pin that has been set since 07-20, the
codec is still HEVC (undecodable in Android Chrome), and the file is still not
faststart. Settings are requests that a subsystem may ignore -- targetResolution
did for weeks, targetBitRate and fileType still do. A gate does not ask.

The gate sits in the upload path, not the export path: Share and Save-to-Photos
keep full capture quality. The conditioned path is persisted so an after-kill
resume re-uploads the same bytes rather than a fresh encode.

Also stop swallowing import normalization failures: the fallback to the original
bytes is correct, being silent about it is not.
The gate only ran in uploadMerged. uploadSegments sent every clip exactly as
recorded.

That mattered because `uploadUnit` is chosen by the DESTINATION SERVER, not by
the app -- the pairing decides whether a draft uploads as one merged video or as
separate clips. So a server asking for segments silently turned the contract
off: no warning, no log, the protection simply did not run. Same shape as the
bug this whole change exists for -- a safeguard that looks present on every
path and isn't.

Conditioning for segments goes to a stable path (drafts/{id}/upload/{seg}.mp4)
rather than a cache temp name, because segment uploads resume byte-wise via TUS
HEAD + PATCH: a resumed run has to send the bytes it began with, and re-encoding
on resume would splice a second, subtly different encode into a half-finished
transfer. A fixed path means a resumed run finds what it already produced. It
lives inside the draft dir so deleteDraftDir reclaims it, and beside segments/
rather than in it so it can never be taken for a clip.

The merged path solves the same problem by persisting its conditioned path in
the draft row, which segments have no column for.
@jlocala1
jlocala1 force-pushed the fix/upload-contract branch from 446dc3b to b0bfdc8 Compare August 5, 2026 13:49
Copilot AI review requested due to automatic review settings August 5, 2026 13:49

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 8 out of 8 changed files in this pull request and generated no new comments.

Suppressed comments (4)

src/utils/ensure-upload-contract.ts:55

  • ensureUploadContract calls probeVideo(path) with the raw path string. On Android, merged.path can be a bare filesystem path (see upload-manager.ts:602-604), and other call sites normalize to file:// before using native APIs. Normalizing here avoids probing failing open and silently skipping enforcement on Android.
export async function ensureUploadContract(path: string): Promise<ContractResult> {
  const probe = await probeVideo(path).catch((e: unknown) => {
    console.warn('[contract] probe failed; uploading the original', e);
    return null;
  });
  if (!probe) {
    return { path, changed: false, reasons: [], failure: 'could not probe the file' };
  }

  const decision = decideUploadContract(probe);
  if (decision.action === 'passthrough') {
    return { path, changed: false, reasons: [] };
  }

src/utils/ensure-upload-contract.ts:73

  • After normalizing the input to a file:// URI, compress() should also use the normalized URI (not the original path) to avoid platform-dependent failures. Additionally, returning a normalized output path keeps the ContractResult.path contract of being immediately usable by Expo File.
  const result = await compress(path, { ...decision.options, outputExt: 'mp4' }).catch(
    (e: unknown) => {
      console.warn('[contract] re-encode failed; uploading the original', decision.reasons, e);
      return null;
    },
  );
  if (!result) {
    return {
      path,
      changed: false,
      reasons: decision.reasons,
      failure: `could not re-encode (${decision.reasons.join(', ')})`,
    };
  }

  return { path: result.outputPath, changed: true, reasons: decision.reasons };
}

src/utils/ensure-upload-contract.ts:106

  • If uploadDest(draftId, segmentId) already exists but is empty/corrupt (e.g. prior crash during move), File.move(dest) may fail because the destination exists. Deleting any existing dest before moving avoids getting stuck in a retry loop where the corrupted file prevents parking the conditioned bytes.
  try {
    // compress() writes into the OS-purgeable cache dir; move it somewhere a resume can find it.
    await new File(toFileUri(result.path)).move(dest);
    return { ...result, path: dest.uri };

src/utils/upload-contract.ts:12

  • The module doc defines the upload contract as including faststart, but the decision logic explicitly cannot observe/enforce moov placement (and there is no faststart-specific step in the gate). As written, a clip can be considered compliant (or normalized) while still being moov-at-end. Please clarify where faststart is actually enforced, or add an explicit faststart remux step in the upload gate.
 * Everything PulseCam uploads must satisfy:
 *
 *     H.264 · long edge <= 1920 · <= 5 Mbps · AAC · faststart
 *
 * This is not a new target — it is what the recorder already asks for

@morepriyam morepriyam left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed and verified this against #143 locally.

Compose check. I fetched pull/142/head and rebased it onto #143's head. Zero conflicts, exactly as claimed. On the combined branch, tsc --noEmit is clean and all 156 tests pass, including the e2e suite with real ffmpeg. The two PRs really are complementary with no code overlap.

Your findings check out. I verified the HybridFrameRecorder analysis against the vision-camera source. createRecorder does wrap AVCaptureMovieFileOutput, which has no faststart API at all, so that patch was dead code. #143 dropped it and now documents that raw per-clip files stay moov-at-end, with faststart handled by the merge layer, this gate, and the pulsevault backstop.

One measurement update in your favor. #143 now bumps vision-camera to 5.2.2, because 5.2.0's setOutputSettings crashed the whole app with an uncatchable NSException (upstream mrousavy/react-native-vision-camera#4037, fixed in #4081). A side effect of that fix is that codec and bitrate get written together in one minimal dict, so the 5 Mbps target actually lands now. A 2-segment trim and merge from an iPhone 17 Pro Max probes h264, 1080p, 5.50 Mbps, AAC, faststart. On current builds a compliant file costs this gate exactly one probe, which is the end state you described. The gate is still worth having for the exact reason you argued: it catches the next setting that silently stops being honored.

Landing order stands: #143 first, then this rebases and lands as the enforcement layer. LGTM.

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