Fix preview wedge after flip: remount SkiaCamera on facing change and as watchdog recovery

Flip only reconfigured the CameraSession in place, leaving the Skia
offscreen-surface cache from the old lens wedged on this Xiaomi: after a
flip the preview banded then went black while the frame pipeline kept
delivering (heartbeat kept rising) and only a full app restart revived it.

- key <SkiaCamera> by facing + mount nonce so a flip fully unmounts and
  remounts the camera (lib clears its surface cache on unmount), i.e. a
  clean restart on every lens switch instead of an in-place reconfigure.
- watchdog now recovers by the same full remount instead of an isActive
  toggle (which never revives a wedged preview), budgeted to 3 restarts.
- bump a re-render on every onStarted: after a remount cameraActive never
  drops (no onStopped on unmount) so the reopen did not re-render and the
  new controller was never re-read, leaving the 1x-10x zoom chips hidden
  until the next touch.

Verified on device: 15+ flips with no wedge, chips visible immediately
after each flip, preview stays live (was ~5 min to black before).
This commit is contained in:
2026-09-09 20:49:24 +07:00
parent 999f49b87f
commit 568f06306a
+85 -1
View File
@@ -298,11 +298,20 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
// flicker-free (preview EV is software matrix gain).
const skiaCameraRef = useRef<SkiaCameraRef>(null);
const [cameraActive, setCameraActive] = useState(false);
// Re-render kicker bumped on every onStarted. A flip remounts <SkiaCamera>
// (key change) while cameraActive never drops (unmount fires no onStopped),
// so the reopen would not re-render and controller-derived UI (zoom chips)
// stayed stuck hidden until the next touch — the new controller only becomes
// readable from the ref once the fresh session has started.
const [, bumpSessionStart] = useState(0);
// Stable identities so React.memo(SkiaCameraImpl) does not re-render (and
// re-run its frame-output setup) on every adjustments change during slider
// drags — inline lambdas here used to break memo and stall the main thread.
const handleCameraStarted = useCallback(() => {
startedOnceRef.current = true;
watchdogBudgetRef.current = 0;
setCameraActive(true);
bumpSessionStart((n) => n + 1);
}, []);
const handleCameraStopped = useCallback(() => {
setCameraActive(false);
@@ -310,6 +319,19 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
// Camera session pauses when the app backgrounds; toggling isActive back on
// resume restarts it. Without this the preview stayed black after unlock.
const [appActive, setAppActive] = useState(AppState.currentState === 'active');
const startedOnceRef = useRef(false);
const cameraActiveRef = useRef(false);
cameraActiveRef.current = cameraActive;
const appActiveRef = useRef(appActive);
appActiveRef.current = appActive;
// Watchdog bookkeeping — see the camera-watchdog effect near handleFrame.
const bouncePendingRef = useRef(false);
const watchdogBudgetRef = useRef(0);
// Full SkiaCamera remount counter. Bumping unmounts <SkiaCamera> and mounts a
// fresh one (its unmount clears the lib's offscreen surface cache), the only
// reliable revive on this device — an isActive toggle or a plain session
// reconfigure (flip) does NOT clear the wedged Skia/EGL state.
const [cameraMountNonce, setCameraMountNonce] = useState(0);
useEffect(() => {
const sub = AppState.addEventListener('change', (s) => setAppActive(s === 'active'));
return () => sub.remove();
@@ -561,6 +583,10 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
// the preview flicker. All live values flow through the synchronizables.
type SkiaCameraOnFrame = NonNullable<ComponentProps<typeof SkiaCamera>['onFrame']>;
const toneFailLogged = useMemo(() => createSynchronizable<boolean>(false), []);
// Frame heartbeat for the camera watchdog: bumped in handleFrame on every
// delivered frame. When the whole pipeline dies the session still reports
// OPEN but frame delivery stops, so the heartbeat freezes.
const frameBeatSync = useMemo(() => createSynchronizable<number>(0), []);
// Composition band fed to the frame worklet: [active, viewAspect, fx, fy,
// fw, fh] where (fx,fy,fw,fh) is the photo-area rect (ratio band / polaroid
// window / wall window) normalized to the view. While active the worklet
@@ -578,6 +604,7 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
const handleFrame = useCallback<SkiaCameraOnFrame>(
(frame, render) => {
'worklet';
frameBeatSync.setBlocking(frameBeatSync.getDirty() + 1);
render(({ canvas, frameTexture }) => {
// Quick-EV drags write colorMatrixSync from JS on every tick; a torn
// read can throw inside MakeMatrix (or yield NaN -> black surface) for
@@ -727,7 +754,7 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
});
frame.dispose();
},
[colorMatrixSync, toneSync, cinemaSync, toneFailLogged, feedRectSync]
[colorMatrixSync, toneSync, cinemaSync, toneFailLogged, feedRectSync, frameBeatSync]
);
const grainOpacity = adjustments.grain / 20;
@@ -803,6 +830,62 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
: [0, 0, 0, 0, 0, 0]
);
}, [cameraPhotoRect, vw, vh, feedRectSync]);
// Camera watchdog: the Xiaomi wedges the preview — horizontal banding then
// black, while the native session still logs OPEN (no error, no onStopped)
// and only a full app restart revives it (a flip = session reconfigure does
// NOT clear the wedged Skia state). Every 3s, while the app is foregrounded
// in camera mode and a session has started at least once: remount the whole
// SkiaCamera (unmount clears the lib surface cache, fresh mount == restart)
// when the session reports inactive, or when the frame heartbeat has not
// advanced for two ticks (~6s) — frame delivery is dead even though the
// session claims to be OPEN. Budgeted to 3 restarts per successful start so
// a genuinely broken session logs out instead of restart-looping forever.
const remountCameraSession = useCallback(() => {
if (bouncePendingRef.current) return;
bouncePendingRef.current = true;
setAppActive(false);
setTimeout(() => {
// Fresh mount while inactive, so the new instance starts clean and
// CLOSED; reopen below once it exists.
setCameraMountNonce((n) => n + 1);
setTimeout(() => {
bouncePendingRef.current = false;
// Never force the camera on while the app is backgrounded.
if (AppState.currentState === 'active') setAppActive(true);
}, 300);
}, 300);
}, []);
useEffect(() => {
if (mode !== 'camera') return;
let lastBeat = -1;
let stallTicks = 0;
const id = setInterval(() => {
const beat = frameBeatSync.getDirty();
if (beat === lastBeat) stallTicks++;
else {
lastBeat = beat;
stallTicks = 0;
}
// Backgrounding pauses the session on purpose — never watchdog that.
if (!startedOnceRef.current || !appActiveRef.current) return;
// Two stale ticks give an in-flight (flip/resume/remount) restart time
// to land.
if (stallTicks < 2) return;
const reason = cameraActiveRef.current ? 'no frames for ~6s' : 'session inactive';
if (watchdogBudgetRef.current >= 3) {
console.warn('[watchdog] camera looks dead (' + reason + ') — restart budget exhausted, giving up');
return;
}
watchdogBudgetRef.current++;
console.warn('[watchdog] remounting camera session: ' + reason);
lastBeat = -1;
stallTicks = 0;
remountCameraSession();
}, 3000);
return () => clearInterval(id);
}, [mode, frameBeatSync, remountCameraSession]);
const onLibTouchStart = (e: any) => {
const ts = e.nativeEvent.touches;
if (ts.length >= 2) {
@@ -1120,6 +1203,7 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
return (
<View className="flex-1 relative overflow-hidden bg-black">
<SkiaCamera
key={'cam-' + facing + '-' + cameraMountNonce}
ref={skiaCameraRef}
style={StyleSheet.absoluteFill}
device={device}