Skip to content

FCE-3602: Create mobile custom source high level API#560

Open
MiloszFilimowski wants to merge 20 commits into
mainfrom
feat/vision-camera-source
Open

FCE-3602: Create mobile custom source high level API#560
MiloszFilimowski wants to merge 20 commits into
mainfrom
feat/vision-camera-source

Conversation

@MiloszFilimowski

@MiloszFilimowski MiloszFilimowski commented Jul 7, 2026

Copy link
Copy Markdown
Collaborator

Description

  • Add @fishjam-cloud/react-native-custom-video-source — generic custom video source package:
    • useManagedPooledTrack / useManagedForwardTrack lifecycle hooks
    • /webgpu entry point with camera passthrough pipeline (TypeGPU shaders, crop utilities, device/feature setup)
  • Add @fishjam-cloud/react-native-vision-camera-source — VisionCamera v5 → Fishjam adapter:
    • useVisionCameraSource (frame-processor push path) with timestamp + orientation handling
    • /webgpu tier: useVisionCameraWebGpuSource for GPU-processed camera frames (iOS 17+)
  • react-client: stabilize useCustomSource setStream, drop useManagedCustomSource
  • webrtc-client: remove custom-source track type override
  • Bump react-native-webrtc submodule; wire both packages into release automation

Motivation and Context

Productizes the custom video track primitive: lets apps publish VisionCamera (optionally WebGPU-processed) frames as a Fishjam video track, with the generic source layer split out for reuse beyond VisionCamera.

Documentation impact

  • Documentation update required
  • Documentation updated in another PR
  • No documentation update required

Types of changes

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to
    not work as expected)

VisionCamera v5 frame outputs that publish camera frames to Fishjam —
zero-copy forwarding and a WebGPU-rendered video source.

- New package packages/react-native-vision-camera-source (useVisionCameraSource
  + /webgpu tier, forward/pooled track management, frame timestamp + orientation
  helpers).
- react-client/mobile-client: useCustomSource gains a UseCustomSourceOptions
  argument so a source can publish its video as a regular camera track
  (videoType: 'camera' — a "virtual camera").
- webrtc-client: route createDataChannel through createNewConnection so a
  data-channel-first connection still wires ICE/track managers (tracks added
  later were silently never published).
- Bump packages/react-native-webrtc to the custom-video-track native support.
- Add the package to the release workflow + workspaces.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@MiloszFilimowski
MiloszFilimowski force-pushed the feat/vision-camera-source branch from d0356df to 8572830 Compare July 9, 2026 08:33
MiloszFilimowski and others added 15 commits July 9, 2026 10:35
A custom source is custom by definition — publishing its video as a
`camera` track was a footgun. Drop UseCustomSourceOptions /
CustomSourceTrackTypes and the videoType threading across react-client,
mobile-client, and react-native-vision-camera-source.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
setStream was referentially unstable because its useCallback closed over
the sources state, so it changed identity on every publish. Read sources
through a ref instead; setStream is now stable.

With that fixed, the useManagedCustomSource adapter (which existed only to
work around the instability via a ref) is redundant — inline its publish
effect into useVisionCameraSource and useVisionCameraWebGpuSource.

Add useCustomSourceManager tests pinning setStream stability and the
publish/unpublish lifecycle.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Prep for the TGSL shader port: JS-body TGSL needs the unplugin-typegpu
build transform, which tsc can't run. Move the package build to bob with
a babel.config.js that runs @babel/preset-typescript + unplugin-typegpu.

- module target (configFile:true) so bob defers to babel.config.js;
  verified the built output preserves 'worklet' directives (for the
  consumer's react-native-worklets plugin) and resolves TGSL to WGSL.
- typescript target emits .d.ts to dist/typescript.
- exports/main/types repointed to dist/module + dist/typescript.
- add typegpu, unplugin-typegpu, react-native-builder-bob, babel deps.

No shader code changed yet; shaders still ship as raw WGSL strings.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Port the /webgpu passthrough shader from hand-written WGSL strings to
TypeGPU:

- FrameCropParams is now a d.struct — one source of truth for the WGSL
  struct decl and the 40-byte worklet packer (size derived via d.sizeOf).
- vertexMain / fragmentMain are TGSL functions (tgpu.vertexFn/fragmentFn);
  the crop uniform is a tgpu.bindGroupLayout.
- sampleCamera is a WGSL-bodied tgpu.fn the TGSL fragment calls. It stays
  WGSL because typegpu 0.11 can't resolve a texture_external inside a TGSL
  function; its texture+sampler bindings are declared as raw WGSL too. This
  is the only part not authored in TGSL.
- Public API now exposes the sampleCamera TgpuFn + bindingDeclarations
  instead of a prebuilt shaderCode string (breaking).

The per-frame worklet path stays raw react-native-webgpu — TGSL resolves
to WGSL only at pipeline setup, so no tgpu proxy runs per frame.

Verified: generated WGSL is functionally identical to the previous
hand-written shader across ios/android x mirror (Android YUV decode
byte-identical; only cosmetic naming + an intentional bind-group index
swap differ), so GPU work is unchanged. CPU packing is untouched.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
tgpu['~unstable'].vertexFn/fragmentFn are @deprecated aliases (the feature
graduated to stable); switch to the top-level tgpu.vertexFn/tgpu.fragmentFn.
Same function reference, so the generated WGSL is unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Spell out that the WebGPU entry needs react-native-webgpu, the
unplugin-typegpu Babel plugin (for the TGSL shaders), and iOS 17+ for the
Metal camera-import path — and that the base camera-publishing tier
requires none of these. The package sets no deployment target itself
(pure JS); the app chooses its own.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This PR had bumped the mobile-client Expo plugin's default
IPHONEOS_DEPLOYMENT_TARGET from 15.1 to 17.0, which forces iOS 17 on
every @fishjam-cloud/react-native-client consumer — not just those using
the WebGPU tier. Restore 15.1; apps that need a higher floor (e.g. for
the /webgpu camera-import path) can still set it via
options.ios.iphoneDeploymentTarget.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add warnIfIosVersionUnsupported(): a one-time console.warn when the
/webgpu camera tier runs on iOS < 17, where Metal external-texture camera
import may fail. Surfaces a clear, actionable reason before the cryptic
device/feature error. Called automatically by useCameraWebGpuDevice (the
choke point both shared and bring-your-own-device paths pass through) and
exported for advanced callers that skip the hook. No-op off iOS.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Remove the custom-source manager test added for the setStream stability
fix, per request.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The effect mixed a (async()=>{})().catch() IIFE, mutable pool/created vars
reassigned in three places, and cancellation re-checked after every await.

Extract the two-step allocation into allocatePooledTrack() (which owns the
"free the orphaned pool if the track fails" case), collapse the effect to a
single disposed flag + one allocation holder disposed exactly once, and pull
out named helpers (toWorkletBufferDescriptor, toError, disposePool,
disposeAllocation).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
useManagedForwardTrack was already clean, but tidy it for consistency with
the pooled sibling: guard `disposed` (was `cancelled`) and only store the
created track when not torn down, mirroring useManagedPooledTrack.

Extract the repeated `cause instanceof Error ? cause : new Error(String(cause))`
into a shared internal toError() (4 call sites across the two managed-track
hooks and useCameraWebGpuDevice).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The vision-camera dep was used in only 3 files; the rest (the custom-video
track lifecycle hooks + the WebGPU camera toolkit) is source-agnostic.
Extract it into a new package @fishjam-cloud/react-native-custom-video-source
that publishes your own frames to Fishjam from any source, and make the
VisionCamera package a thin adapter on top of it.

- new package: internal/{useManagedForwardTrack,useManagedPooledTrack,toError}
  + webgpu/* (camera shader bindings, passthrough, crop, render context,
  required features, device, texture resolver, runtime). Owns typegpu +
  unplugin-typegpu (the TGSL shaders live here now).
- vision-camera-source keeps only the VC-coupled files (useVisionCameraSource,
  useVisionCameraWebGpuSource, frameTimestamp, orientation), depends on the
  generic package (workspace:*), and its /webgpu re-exports the generic
  toolkit. Dropped its now-unused typegpu/unplugin-typegpu deps + babel plugin.
- register the new workspace in the root package.json.

Both packages typecheck, build (bob), and lint clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- release: publish + version-bump react-native-custom-video-source, gate
  vision-camera-source publish on it
- vision-camera-source: react-native-worklets as peer dep (native module)
- webgpu: guard shared-device slot against late device-lost callbacks
- docs: fix stale package name in RNWebGPU warning, README outputView
  example, encodeCameraPassthrough single-crop constraint, timestamp
  fallback quirk, drop untrue "asserted in tests" claim
- extract shared usePublishedStream + stopStreamTracks helpers
- log frame-processing failures with the cause object

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@MiloszFilimowski MiloszFilimowski changed the title WIP: Custom Source FCE-3602: Create mobile custom source high level API Jul 10, 2026
@linear

linear Bot commented Jul 10, 2026

Copy link
Copy Markdown

FCE-3602

@MiloszFilimowski
MiloszFilimowski requested review from Magmusacy and czerwiukk and removed request for czerwiukk July 10, 2026 10:27
MiloszFilimowski and others added 2 commits July 10, 2026 12:42
Export ManagedForwardTrack/ManagedPooledTrack — they are the return types of
public hooks, so typedoc (treatWarningsAsErrors) failed on them being absent
from the docs. Exclude external symbols in vision-camera-source docs: the
options interfaces inherit VisionCamera members whose upstream doc comments
carry @link tags unresolvable in our docs. Also turn the one cross-entry-point
@link in webgpu.ts into a plain reference.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… and silent RNWebGPU failure

- Lower the frame-timestamp magnitude threshold from 1e12 to 1e9: both
  platform clocks count from boot, so seconds can never reach 1e9 while
  nanoseconds pass it one second after boot. The old threshold
  misclassified Android frames captured within ~16.7 minutes of boot.
- Catch setStream rejections in usePublishedStream (the same-tick
  removeTracks race rejects) and downgrade them to warnings.
- Make getWebGpuRuntime throw a descriptive error when install() cannot
  bind the RNWebGPU global, and surface that through the WebGPU source
  hook's `error` result instead of crashing render or failing per frame.
- Add vitest + a frameTimestamp regression suite (fails on the old
  threshold, passes on the fix).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@MiloszFilimowski
MiloszFilimowski force-pushed the feat/vision-camera-source branch 4 times, most recently from c208ac7 to 28f9e46 Compare July 10, 2026 11:57
Replacing a published stream issues an unpublish and a publish in the same
tick; run concurrently they raced to remove the same track IDs and the new
stream was nondeterministically never published. setStream calls now run
through a promise chain, with a synchronously-updated sources snapshot so
queued calls see each other's effects. The connected effect also no longer
leaves addTrack rejections unhandled.

useVisionCameraSource now supplies timestampNs on every frame (interval-paced
fallback via nextFrameTimestampNanoseconds, like the WebGPU tier), clamped to
at least 1 ns: native reads an omitted timestamp — and the value 0, the
timeline's first frame — as "stamp with my own clock", which mixed two
timestamp domains on one track.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@MiloszFilimowski
MiloszFilimowski force-pushed the feat/vision-camera-source branch from 28f9e46 to 7b1ea86 Compare July 10, 2026 12:33
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.

1 participant