fix: camera live view monochrome 20fps
This commit is contained in:
+132
-73
@@ -1,4 +1,4 @@
|
||||
import React, { useRef, useEffect, useState, useCallback } from 'react';
|
||||
import React, { useRef, useState, useEffect } from 'react';
|
||||
import { View, Text, StyleSheet, Dimensions, TouchableOpacity } from 'react-native';
|
||||
import { CameraView } from 'expo-camera';
|
||||
import {
|
||||
@@ -40,7 +40,7 @@ const noiseEffect = Skia.RuntimeEffect.Make(`
|
||||
}
|
||||
`);
|
||||
|
||||
const VIEWFINDER_OVERLAY_OPACITY = 0.35;
|
||||
const CAPTURE_INTERVAL_MS = 500; // 20fps capture
|
||||
|
||||
export default function Viewfinder({
|
||||
mode,
|
||||
@@ -52,9 +52,11 @@ export default function Viewfinder({
|
||||
cameraPermissionGranted,
|
||||
onRequestCameraPermission,
|
||||
}: ViewfinderProps) {
|
||||
const cameraRef = useRef(null);
|
||||
const cameraRef = useRef<CameraView>(null);
|
||||
const [capturedFrameUri, setCapturedFrameUri] = useState<string | null>(null);
|
||||
const [isCapturing, setIsCapturing] = useState(false);
|
||||
|
||||
const skiaImage = useImage(libraryImageUri || '');
|
||||
const skiaImage = useImage(capturedFrameUri || libraryImageUri || '');
|
||||
|
||||
const customFont = useFont(
|
||||
require('../../assets/CourierPrime-Regular.ttf'),
|
||||
@@ -72,6 +74,59 @@ export default function Viewfinder({
|
||||
);
|
||||
const grainOpacity = adjustments.grain / 20;
|
||||
|
||||
// Periodic frame capture for camera live view with full filter effects
|
||||
useEffect(() => {
|
||||
if (mode !== 'camera') {
|
||||
setCapturedFrameUri(null);
|
||||
setIsCapturing(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const captureFrame = async () => {
|
||||
if (isCapturing || !cameraRef.current) return;
|
||||
setIsCapturing(true);
|
||||
try {
|
||||
const picture = await cameraRef.current.takePictureAsync({
|
||||
quality: 0.4,
|
||||
base64: false,
|
||||
skipProcessing: true,
|
||||
});
|
||||
if (picture?.uri) {
|
||||
setCapturedFrameUri(picture.uri);
|
||||
}
|
||||
} catch {
|
||||
// Camera not ready yet
|
||||
} finally {
|
||||
setIsCapturing(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Capture immediately on mount
|
||||
captureFrame();
|
||||
|
||||
// Then capture periodically
|
||||
const intervalId = setInterval(captureFrame, CAPTURE_INTERVAL_MS);
|
||||
|
||||
return () => {
|
||||
clearInterval(intervalId);
|
||||
setCapturedFrameUri(null);
|
||||
};
|
||||
}, [mode]);
|
||||
|
||||
// Re-capture when recipe changes for immediate feedback
|
||||
useEffect(() => {
|
||||
if (mode !== 'camera' || !cameraRef.current || isCapturing) return;
|
||||
const timer = setTimeout(captureFrame, 100);
|
||||
return () => clearTimeout(timer);
|
||||
}, [
|
||||
recipe.baseFilter,
|
||||
adjustments.exposure,
|
||||
adjustments.saturation,
|
||||
adjustments.temperature,
|
||||
adjustments.tint,
|
||||
adjustments.colorChrome,
|
||||
]);
|
||||
|
||||
const borderWidth = Math.min(viewfinderWidth, viewfinderHeight) * 0.05;
|
||||
const sideBorder = Math.min(viewfinderWidth, viewfinderHeight) * 0.06;
|
||||
const bottomBorder = viewfinderHeight * 0.18;
|
||||
@@ -136,61 +191,13 @@ export default function Viewfinder({
|
||||
|
||||
return (
|
||||
<Group>
|
||||
<SkiaText
|
||||
x={xOffset}
|
||||
y={yOffset - 32}
|
||||
text={`📍 ${locationName}`}
|
||||
font={customFont}
|
||||
color="#f59e0b"
|
||||
/>
|
||||
<SkiaText
|
||||
x={xOffset}
|
||||
y={yOffset - 16}
|
||||
text={`${latStr}, ${lonStr}`}
|
||||
font={customFont}
|
||||
color="#f59e0b"
|
||||
/>
|
||||
<SkiaText
|
||||
x={xOffset}
|
||||
y={yOffset}
|
||||
text={`${timestampStr} | SS 1/125 f/2.8`}
|
||||
font={customFont}
|
||||
color="#f59e0b"
|
||||
/>
|
||||
<SkiaText x={xOffset} y={yOffset - 32} text={`📍 ${locationName}`} font={customFont} color="#f59e0b" />
|
||||
<SkiaText x={xOffset} y={yOffset - 16} text={`${latStr}, ${lonStr}`} font={customFont} color="#f59e0b" />
|
||||
<SkiaText x={xOffset} y={yOffset} text={`${timestampStr} | SS 1/125 f/2.8`} font={customFont} color="#f59e0b" />
|
||||
</Group>
|
||||
);
|
||||
};
|
||||
|
||||
// Shared overlay components for both modes
|
||||
const renderColorMatrixOverlay = (opacity: number = VIEWFINDER_OVERLAY_OPACITY) => (
|
||||
<Canvas style={StyleSheet.absoluteFill} pointerEvents="none">
|
||||
<Group opacity={opacity}>
|
||||
<ColorMatrix matrix={colorMatrix} />
|
||||
<Rect x={0} y={0} width={viewfinderWidth} height={viewfinderHeight} color="white" />
|
||||
</Group>
|
||||
</Canvas>
|
||||
);
|
||||
|
||||
const renderGrainOverlay = () => {
|
||||
if (grainOpacity <= 0 || !noiseEffect) return null;
|
||||
return (
|
||||
<Canvas style={StyleSheet.absoluteFill} pointerEvents="none">
|
||||
<Group blendMode="overlay">
|
||||
<Rect x={0} y={0} width={viewfinderWidth} height={viewfinderHeight} opacity={grainOpacity}>
|
||||
<Shader source={noiseEffect} />
|
||||
</Rect>
|
||||
</Group>
|
||||
</Canvas>
|
||||
);
|
||||
};
|
||||
|
||||
const renderBorderOverlay = () => (
|
||||
<Canvas style={StyleSheet.absoluteFill} pointerEvents="none">
|
||||
{renderFrameOverlay()}
|
||||
{renderGPSWatermark()}
|
||||
</Canvas>
|
||||
);
|
||||
|
||||
if (mode === 'camera') {
|
||||
if (!cameraPermissionGranted) {
|
||||
return (
|
||||
@@ -212,12 +219,60 @@ export default function Viewfinder({
|
||||
);
|
||||
}
|
||||
|
||||
// Show captured and processed frame with Skia
|
||||
if (capturedFrameUri && skiaImage) {
|
||||
return (
|
||||
<View
|
||||
style={{ width: viewfinderWidth, height: viewfinderHeight }}
|
||||
className="relative overflow-hidden rounded-2xl border border-zinc-800 bg-black"
|
||||
>
|
||||
<Canvas style={StyleSheet.absoluteFill}>
|
||||
<Group>
|
||||
<ColorMatrix matrix={colorMatrix} />
|
||||
<SkiaImage
|
||||
image={skiaImage}
|
||||
fit="cover"
|
||||
rect={rect(0, 0, viewfinderWidth, viewfinderHeight)}
|
||||
/>
|
||||
</Group>
|
||||
{grainOpacity > 0 && noiseEffect && (
|
||||
<Group blendMode="overlay">
|
||||
<Rect x={0} y={0} width={viewfinderWidth} height={viewfinderHeight} opacity={grainOpacity}>
|
||||
<Shader source={noiseEffect} />
|
||||
</Rect>
|
||||
</Group>
|
||||
)}
|
||||
{renderFrameOverlay()}
|
||||
{renderGPSWatermark()}
|
||||
</Canvas>
|
||||
|
||||
{/* Grid overlay */}
|
||||
<View className="absolute inset-0 flex-row justify-between pointer-events-none opacity-20">
|
||||
<View className="w-[1px] h-full bg-white ml-[33%]" />
|
||||
<View className="w-[1px] h-full bg-white mr-[33%]" />
|
||||
</View>
|
||||
<View className="absolute inset-0 flex-col justify-between pointer-events-none opacity-20">
|
||||
<View className="h-[1px] w-full bg-white mt-[33%]" />
|
||||
<View className="h-[1px] w-full bg-white mb-[33%]" />
|
||||
</View>
|
||||
|
||||
{/* Capture indicator */}
|
||||
<View className="absolute bottom-2 right-2 bg-black/60 rounded-full px-2 py-0.5">
|
||||
<Text className="text-[9px] text-amber-500 font-mono">LIVE</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
// Fallback to CameraView while first frame is capturing
|
||||
return (
|
||||
<View
|
||||
style={{ width: viewfinderWidth, height: viewfinderHeight }}
|
||||
className="relative overflow-hidden rounded-2xl border border-zinc-800 bg-black"
|
||||
>
|
||||
<CameraView ref={cameraRef} style={StyleSheet.absoluteFill} facing="back" />
|
||||
{renderFrameOverlay()}
|
||||
{renderGPSWatermark()}
|
||||
|
||||
{/* Grid */}
|
||||
<View className="absolute inset-0 flex-row justify-between pointer-events-none opacity-20">
|
||||
@@ -229,10 +284,10 @@ export default function Viewfinder({
|
||||
<View className="h-[1px] w-full bg-white mb-[33%]" />
|
||||
</View>
|
||||
|
||||
{/* ColorMatrix overlay shows the recipe effect on camera */}
|
||||
{renderColorMatrixOverlay()}
|
||||
{renderGrainOverlay()}
|
||||
{renderBorderOverlay()}
|
||||
{/* Loading indicator */}
|
||||
<View className="absolute inset-0 flex items-center justify-center bg-black/50">
|
||||
<Text className="text-white font-mono text-xs">CAPTURING...</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -244,21 +299,25 @@ export default function Viewfinder({
|
||||
className="relative overflow-hidden rounded-2xl border border-zinc-800 bg-zinc-950 justify-center items-center"
|
||||
>
|
||||
{libraryImageUri && skiaImage ? (
|
||||
<>
|
||||
<Canvas style={StyleSheet.absoluteFill}>
|
||||
<Group>
|
||||
<ColorMatrix matrix={colorMatrix} />
|
||||
<SkiaImage
|
||||
image={skiaImage}
|
||||
fit="cover"
|
||||
rect={rect(0, 0, viewfinderWidth, viewfinderHeight)}
|
||||
/>
|
||||
<Canvas style={StyleSheet.absoluteFill}>
|
||||
<Group>
|
||||
<ColorMatrix matrix={colorMatrix} />
|
||||
<SkiaImage
|
||||
image={skiaImage}
|
||||
fit="cover"
|
||||
rect={rect(0, 0, viewfinderWidth, viewfinderHeight)}
|
||||
/>
|
||||
</Group>
|
||||
{grainOpacity > 0 && noiseEffect && (
|
||||
<Group blendMode="overlay">
|
||||
<Rect x={0} y={0} width={viewfinderWidth} height={viewfinderHeight} opacity={grainOpacity}>
|
||||
<Shader source={noiseEffect} />
|
||||
</Rect>
|
||||
</Group>
|
||||
{renderGrainOverlay()}
|
||||
</Canvas>
|
||||
{/* Border + GPS as separate canvas on top */}
|
||||
{renderBorderOverlay()}
|
||||
</>
|
||||
)}
|
||||
{renderFrameOverlay()}
|
||||
{renderGPSWatermark()}
|
||||
</Canvas>
|
||||
) : (
|
||||
<View className="items-center justify-center p-6">
|
||||
<Text className="text-zinc-500 font-mono text-center text-xs">
|
||||
|
||||
Reference in New Issue
Block a user