From 568f06306af2c9f899d72708b73e9c5db7b07735 Mon Sep 17 00:00:00 2001 From: locphamtran Date: Wed, 9 Sep 2026 20:49:24 +0700 Subject: [PATCH] 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 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). --- src/components/Viewfinder.tsx | 86 ++++++++++++++++++++++++++++++++++- 1 file changed, 85 insertions(+), 1 deletion(-) diff --git a/src/components/Viewfinder.tsx b/src/components/Viewfinder.tsx index 0c7f1db..9dafaf9 100644 --- a/src/components/Viewfinder.tsx +++ b/src/components/Viewfinder.tsx @@ -298,11 +298,20 @@ const Viewfinder = forwardRef(function Viewfi // flicker-free (preview EV is software matrix gain). const skiaCameraRef = useRef(null); const [cameraActive, setCameraActive] = useState(false); + // Re-render kicker bumped on every onStarted. A flip remounts + // (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(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 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(function Viewfi // the preview flicker. All live values flow through the synchronizables. type SkiaCameraOnFrame = NonNullable['onFrame']>; const toneFailLogged = useMemo(() => createSynchronizable(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(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(function Viewfi const handleFrame = useCallback( (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(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(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(function Viewfi return (