Cut 1.2.3 with a shutter that never waits on GPS

Bound the geotag fix with a 2 s deadline and prewarm it at launch, queue a
shot pressed before the camera session binds and replay it, pass the options
argument capturePhotoToFile requires, save through the native path so the
media permission is not needed, and release the Skia surface and bitmap after
every export. Store the JS bundle uncompressed and load the export pipeline on
demand. Note in RELEASE_NOTES.md that none of this moved the tap threshold.
This commit is contained in:
2026-09-17 10:02:35 +07:00
parent d07dd69f17
commit 76cf4c6d7e
12 changed files with 476 additions and 170 deletions
+2
View File
@@ -515,6 +515,8 @@ export default function LibraryPickerModal({
})()
: null;
if (!visible) return null; // see PhotoViewerModal
return (
<Modal visible={visible} animationType="slide" onRequestClose={onCancel}>
<View className="flex-1 bg-black">
+2
View File
@@ -122,6 +122,8 @@ export default function PhotoViewerModal({
}
};
if (!visible) return null; // see PhotoViewerModal
return (
<Modal visible={visible} animationType="fade" transparent={false} onRequestClose={onClose}>
<View className="flex-1 bg-black">
+2
View File
@@ -24,6 +24,8 @@ const PERKS = [
export default function ProModal({ visible, onClose }: { visible: boolean; onClose: () => void }) {
const { owned, price, busy, error } = useProUnlock();
if (!visible) return null; // see PhotoViewerModal
return (
<Modal visible={visible} transparent animationType="fade" onRequestClose={onClose}>
<View className="flex-1 bg-black/70 items-center justify-center px-4">
+2
View File
@@ -119,6 +119,8 @@ export default function SettingsModal({
hide.remove();
};
}, []);
if (!visible) return null; // see PhotoViewerModal
return (
<Modal visible={visible} transparent animationType="fade" onRequestClose={onClose}>
<View className="flex-1 bg-black/70 items-center justify-center px-6" style={{ paddingBottom: kb }}>
+83 -35
View File
@@ -90,6 +90,11 @@ interface ViewfinderProps {
// Session-level error from the camera (e.g. DNG output failed to bind). App
// auto-disables RAW on such errors and alerts the user.
onSessionError?: (error: Error) => void;
// Live session ready flag for the shutter gate: true once the CameraX
// session is bound (onStarted), false when it goes down (onStopped). App
// queues a shutter press that lands before it — firing one into a dead
// session produced no file at all.
onCameraReady?: (ready: boolean) => void;
// Quick exposure slider (shown while AE/AF is locked) — live AE bias in EV.
onExposureChange?: (ev: number) => void;
// Metering-mode tonal look (highlight-weighted): extra adjustments merged on
@@ -197,6 +202,23 @@ export interface ViewfinderHandle {
// the old per-pixel hash, since the smoothed cell fraction is 0 there. The
// bilinear over the four cell corners is what makes a clump instead of a pixel
// of static — at cell 1 it collapses back to the plain hash.
// Every Skia object the per-frame worklet builds (paint, filter, shader) is a
// native allocation; the UI runtime's GC does not reach them promptly, so a
// preview left open piles up ~24 MB/minute that the device pays back as swap.
// The frame pushes each object here and this releases them after the last draw
// that could still reference them. Reverse order: children were handed refs by
// the parents built after them.
type SkTrash = { dispose: () => void };
const disposeTrash = (trash: SkTrash[]) => {
'worklet';
for (let i = trash.length - 1; i >= 0; i--) {
try {
trash[i].dispose();
} catch {
// A dispose failure must never take the frame down with it.
}
}
};
const noiseEffect = Skia.RuntimeEffect.Make(`
uniform float u;
float grainHash(vec2 q) {
@@ -291,6 +313,7 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
rawEnabled,
rawOutput,
onSessionError,
onCameraReady,
onExposureChange,
meteringAdjustments,
imageAdjustTarget,
@@ -533,10 +556,12 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
watchdogBudgetRef.current = 0;
setCameraActive(true);
bumpSessionStart((n) => n + 1);
}, []);
onCameraReady?.(true);
}, [onCameraReady]);
const handleCameraStopped = useCallback(() => {
setCameraActive(false);
}, []);
onCameraReady?.(false);
}, [onCameraReady]);
// 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');
@@ -1168,12 +1193,17 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
// read can throw inside MakeMatrix (or yield NaN -> black surface) for
// one frame. Validate + try/catch, fall back to identity (no filter):
// a 1-frame gain error beats a 1-frame black beat. DEBUG-EV.
const paint = Skia.Paint();
const trash: SkTrash[] = [];
const keep = <T extends SkTrash>(o: T): T => {
trash.push(o);
return o;
};
const paint = keep(Skia.Paint());
let baseColorFilter: ReturnType<typeof Skia.ColorFilter.MakeMatrix> | null = null;
try {
const cm = colorMatrixSync.getDirty();
if (cm != null && cm.length === 20 && cm.every((v) => typeof v === 'number' && Number.isFinite(v))) {
baseColorFilter = Skia.ColorFilter.MakeMatrix(cm);
baseColorFilter = keep(Skia.ColorFilter.MakeMatrix(cm));
paint.setColorFilter(baseColorFilter);
} else {
console.warn('Invalid color matrix (len ' + (cm == null ? 'null' : cm.length) + ') — filter skipped');
@@ -1194,7 +1224,8 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
const a = (cv / 10) * 0.8;
let lensFilter =
cv > 0
? Skia.ImageFilter.MakeMatrixConvolution(
? keep(
Skia.ImageFilter.MakeMatrixConvolution(
3,
3,
[0, -a, 0, -a, 1 + 4 * a, -a, 0, -a, 0],
@@ -1205,21 +1236,26 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
T_CLAMP,
true,
null
)
)
: cv < 0
? Skia.ImageFilter.MakeBlur(
Math.abs(cv / 10) * 4,
Math.abs(cv / 10) * 4,
T_CLAMP,
null
? keep(
Skia.ImageFilter.MakeBlur(
Math.abs(cv / 10) * 4,
Math.abs(cv / 10) * 4,
T_CLAMP,
null
)
)
: null;
if (sv < 0) {
lensFilter = Skia.ImageFilter.MakeBlur(
Math.abs(sv / 10) * 3,
Math.abs(sv / 10) * 3,
T_CLAMP,
lensFilter
lensFilter = keep(
Skia.ImageFilter.MakeBlur(
Math.abs(sv / 10) * 3,
Math.abs(sv / 10) * 3,
T_CLAMP,
lensFilter
)
);
}
if (lensFilter != null) paint.setImageFilter(lensFilter);
@@ -1244,13 +1280,13 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
let fullShader = null;
if (hasTone || hasCinema) {
try {
const imageShader = frameTexture.makeShaderOptions(T_CLAMP, T_CLAMP, F_LINEAR, M_NONE);
const imageShader = keep(frameTexture.makeShaderOptions(T_CLAMP, T_CLAMP, F_LINEAR, M_NONE));
let final = null;
if (hasTone) {
final = toneEffect.makeShaderWithChildren(tone, [imageShader]);
final = keep(toneEffect.makeShaderWithChildren(tone, [imageShader]));
}
if (hasCinema) {
const cinemaShader = cinemaEffect.makeShaderWithChildren(cinema, [final ?? imageShader]);
const cinemaShader = keep(cinemaEffect.makeShaderWithChildren(cinema, [final ?? imageShader]));
if (cinemaShader != null) final = cinemaShader;
}
if (final != null) fullShader = final;
@@ -1283,10 +1319,10 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
const srcShader =
fullShader != null
? fullShader
: frameTexture.makeShaderOptions(T_CLAMP, T_CLAMP, F_LINEAR, M_NONE);
: keep(frameTexture.makeShaderOptions(T_CLAMP, T_CLAMP, F_LINEAR, M_NONE));
const glowShader =
glowEffect != null ? glowEffect.makeShaderWithChildren(glowUniformArray(), [srcShader]) : null;
const glowPaint = Skia.Paint();
glowEffect != null ? keep(glowEffect.makeShaderWithChildren(glowUniformArray(), [srcShader])) : null;
const glowPaint = keep(Skia.Paint());
glowPaint.setBlendMode(BlendMode.Screen);
glowPaint.setAlphaf(0.15 + 0.35 * strength);
if (glowShader != null) {
@@ -1294,10 +1330,15 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
// Recipe matrix stays where it always was — inside the filter
// chain, under the blur — so the bloom lands on GRADED pixels
// (a monochrome recipe glows white, not colour).
const glowBlur = Skia.ImageFilter.MakeBlur(sigma, sigma, TileMode.Clamp, null);
const glowBlur = keep(Skia.ImageFilter.MakeBlur(sigma, sigma, TileMode.Clamp, null));
glowPaint.setImageFilter(
baseColorFilter != null
? Skia.ImageFilter.MakeCompose(glowBlur, Skia.ImageFilter.MakeColorFilter(baseColorFilter, null))
? keep(
Skia.ImageFilter.MakeCompose(
glowBlur,
keep(Skia.ImageFilter.MakeColorFilter(baseColorFilter, null))
)
)
: glowBlur
);
canvas.drawRect(Skia.XYWHRect(0, 0, width, frameTexture.height()), glowPaint);
@@ -1312,16 +1353,22 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
];
// Order is load-bearing (see exportEngine pass 5b): chain both as
// image filters so the highlight selection runs BEFORE the blur.
const selection = Skia.ImageFilter.MakeColorFilter(
baseColorFilter != null
? Skia.ColorFilter.MakeCompose(Skia.ColorFilter.MakeMatrix(hiMatrix), baseColorFilter)
: Skia.ColorFilter.MakeMatrix(hiMatrix),
null
const selection = keep(
Skia.ImageFilter.MakeColorFilter(
baseColorFilter != null
? keep(
Skia.ColorFilter.MakeCompose(keep(Skia.ColorFilter.MakeMatrix(hiMatrix)), baseColorFilter)
)
: keep(Skia.ColorFilter.MakeMatrix(hiMatrix)),
null
)
);
glowPaint.setImageFilter(
Skia.ImageFilter.MakeCompose(
Skia.ImageFilter.MakeBlur(sigma, sigma, TileMode.Clamp, null),
selection
keep(
Skia.ImageFilter.MakeCompose(
keep(Skia.ImageFilter.MakeBlur(sigma, sigma, TileMode.Clamp, null)),
selection
)
)
);
if (fullShader != null) {
@@ -1347,7 +1394,7 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
const bandActive = feed[0] > 0;
if (!bandActive) {
drawFullFrame();
return;
return disposeTrash(trash);
}
const isLand = orient === 'left' || orient === 'right';
const ow = isLand ? ftH : ftW;
@@ -1356,7 +1403,7 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
const viewAspect = feed[1];
if (!(ow > 0 && oh > 0 && ac > 0 && viewAspect > 0 && feed[4] > 0 && feed[5] > 0)) {
drawFullFrame();
return;
return disposeTrash(trash);
}
// View -> upright-snapshot mapping: the snapshot is cover-fitted to the
// view, so the view shows a centered visX x visY window of it.
@@ -1407,7 +1454,7 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
const rh = v1 - v0;
if (!(rw > 0 && rh > 0)) {
drawFullFrame();
return;
return disposeTrash(trash);
}
// Cover-map the full sensor into the raw rect (dst preserves the sensor
// aspect, centered; the clip below trims the overflow) — translate then
@@ -1439,7 +1486,7 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
const k = s * z;
// Fresh paint; its default color is opaque black (Skia default), so
// the surface fill below needs no explicit color set.
const blackPaint = Skia.Paint();
const blackPaint = keep(Skia.Paint());
canvas.drawRect(Skia.XYWHRect(0, 0, ftW, ftH), blackPaint);
canvas.save();
canvas.clipRect(Skia.XYWHRect(u0, v0, rw, rh), CLIP_INTERSECT, false);
@@ -1447,6 +1494,7 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
canvas.scale(k, k);
drawFullFrame();
canvas.restore();
disposeTrash(trash);
});
frame.dispose();
},