Sell PRO as an in-app unlock, take shared photos, and let FX run negative
- One free Play app (com.locphamtran.recipescamera) with an expo-iap PRO unlock, replacing the paid apk for the store channel - Android SEND/SEND_MULTIPLE share target feeds photos into the editor, on cold start and on resume - BACK button in the TopBar and press-and-hold PEEK replace the chip row - NOISE REDUCTION and SHARPENING accept -10: a negative SHARPENING softens the frame before the grain pass, a negative NOISE REDUCTION re-grains it, in the Skia engine, the live preview and the Kotlin export path alike
This commit is contained in:
+105
-7
@@ -2,6 +2,7 @@ package com.locphamtran.recipescamera.export
|
||||
|
||||
import android.content.ContentValues
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import android.graphics.Canvas
|
||||
@@ -11,6 +12,7 @@ import android.graphics.Paint
|
||||
import android.graphics.Rect
|
||||
import android.graphics.RectF
|
||||
import android.graphics.Typeface
|
||||
import android.net.Uri
|
||||
import android.os.Environment
|
||||
import android.provider.MediaStore
|
||||
import android.util.Base64
|
||||
@@ -77,6 +79,23 @@ class RecipescamExportModule : Module() {
|
||||
Name("RecipesCamExport")
|
||||
Log.i(TAG, "module defined RecipesCamExport")
|
||||
|
||||
// System share sheet intake. AndroidManifest declares this app a share
|
||||
// target (ACTION_SEND / ACTION_SEND_MULTIPLE, image/*), and the shared photo
|
||||
// exists only as the intent's EXTRA_STREAM — a content:// Uri that no JS API
|
||||
// here can read (expo-linking surfaces VIEW data, never stream extras, and on
|
||||
// 13+ the sender's uri is short-lived and redacted). So the stream is copied
|
||||
// natively into the cache and handed to JS as an ordinary file that the normal
|
||||
// library pipeline (path + assetId -> EXIF/MediaStore GPS) reads.
|
||||
AsyncFunction("takeSharedImage") Coroutine { ->
|
||||
// Read live, never cached: launchMode=singleTask re-delivers a share that
|
||||
// arrives while we are alive through onNewIntent -> setIntent, so the
|
||||
// activity's intent is always the current one and no native hook is needed.
|
||||
val ctx = appContext.reactContext
|
||||
val activity = appContext.currentActivity
|
||||
if (ctx == null || activity == null) emptyMap<String, String>()
|
||||
else withContext(Dispatchers.IO) { consumeSharedImage(ctx, activity.intent) }
|
||||
}
|
||||
|
||||
// Save an encoded JPEG/PNG into DCIM/Camera. expo-media-library's JS API
|
||||
// only ever inserts into DCIM (root) or Pictures/Camera, so a camera-roll
|
||||
// export lands where the stock camera puts its shots by inserting the
|
||||
@@ -337,6 +356,9 @@ private suspend fun processPipeline(
|
||||
val aspect = adjust["aspect"] as? String
|
||||
val dpi = (adjust["dpi"] as? Number)?.toInt() ?: 0
|
||||
val sharpen = (adjust["sharpen"] as? Number)?.toDouble() ?: 0.0
|
||||
// Negative SHARPENING knob (engine step 4): pre-grain gaussian soften, must
|
||||
// run before the grain pass or the blur eats the re-grained noise.
|
||||
val soften = (adjust["soften"] as? Number)?.toDouble() ?: 0.0
|
||||
require(matrixList.size == 20) { "color matrix needs 20 floats, got ${matrixList.size}" }
|
||||
val matrix = FloatArray(20) { i -> (matrixList[i] as? Number)?.toFloat() ?: 0f }
|
||||
val cinemaFlat = FloatArray(16) { i -> (cinemaList?.getOrNull(i) as? Number)?.toFloat() ?: 0f }
|
||||
@@ -434,11 +456,13 @@ private suspend fun processPipeline(
|
||||
Log.i(TAG, "centerPixel rgb=(${android.graphics.Color.red(center)},${android.graphics.Color.green(center)},${android.graphics.Color.blue(center)})")
|
||||
bmp.recycle()
|
||||
|
||||
// 5. Denoise / clarity / grain (exportEngine #4 + #6), CPU passes on the
|
||||
// matrix output. Engine chain: denoise blur -> clarity conv (or mist blur),
|
||||
// then a grain Overlay rect on top.
|
||||
val needEnhance = denoise > 0 || clarity != 0.0
|
||||
val needGrain = grain > 0
|
||||
// 5. Denoise / clarity / soften / grain (exportEngine #4 + #6), CPU passes
|
||||
// on the matrix output. Engine chain: denoise blur -> clarity conv (or mist
|
||||
// blur) -> soften blur for a negative SHARPENING knob, then a grain Overlay
|
||||
// rect on top. A negative NOISE REDUCTION knob adds re-grain instead of
|
||||
// blurring, so a -10/-10 photo comes out soft AND grainy.
|
||||
val needEnhance = denoise > 0 || clarity != 0.0 || soften > 0
|
||||
val needGrain = grain > 0 || denoise < 0
|
||||
if (needEnhance || needGrain) {
|
||||
val t6 = System.nanoTime()
|
||||
val wO = out.width
|
||||
@@ -456,10 +480,16 @@ private suspend fun processPipeline(
|
||||
val mistSigma = (abs(clarity) / 10.0) * 4.0 // bloom/mist
|
||||
p = boxBlur(p, wO, hO, max(1, mistSigma.roundToInt()))
|
||||
}
|
||||
// Negative SHARPENING: same 3px-max gaussian the engine puts in the
|
||||
// filter chain before grain (MakeBlur sigma = |knob|/10*3).
|
||||
if (soften > 0) p = boxBlur(p, wO, hO, max(1, soften.roundToInt()))
|
||||
if (needEnhance) timings["clarityMs"] = ((System.nanoTime() - t6) / 1_000_000).toInt()
|
||||
val t7 = System.nanoTime()
|
||||
if (grain > 0) {
|
||||
val opacity = (grain / 20.0).toFloat().coerceIn(0f, 1f) // engine: grain/20
|
||||
// Engine #6 grain amount: positive grain plus the re-grain a negative
|
||||
// NOISE REDUCTION knob asks for, clamped to the 0..10 knob range.
|
||||
val g = min(10.0, max(0.0, grain) + max(0.0, -denoise))
|
||||
if (g > 0) {
|
||||
val opacity = (g / 20.0).toFloat().coerceIn(0f, 1f) // engine: grain/20
|
||||
p = grainOverlay(p, wO, hO, opacity)
|
||||
timings["grainMs"] = ((System.nanoTime() - t7) / 1_000_000).toInt()
|
||||
Log.i(TAG, "grain applied opacity=$opacity")
|
||||
@@ -1216,3 +1246,71 @@ private fun parityCompare(ctx: Context, pathA: String, pathB: String, outPath: S
|
||||
"gt32pct" to (gt[2] * 100.0 / total),
|
||||
)
|
||||
}
|
||||
|
||||
// ---- system share intake (AndroidManifest ACTION_SEND) ---------------------
|
||||
|
||||
/**
|
||||
* The photo a share handed this launch, copied out of the sender's content://
|
||||
* stream into our own cache and returned as { path, assetId, type } — all empty
|
||||
* when this launch was not a share, or when the share was already taken.
|
||||
*
|
||||
* The copy is what lets the rest of the app work unchanged: every other entry
|
||||
* point (LibraryPickerModal, the export pipeline, the EXIF read) wants a real
|
||||
* file, and the sender's read grant only ever covers this one activity.
|
||||
*
|
||||
* ponytail: only the FIRST stream is read, so ACTION_SEND_MULTIPLE's 2nd..nth
|
||||
* images are dropped — the editor works on one photo at a time; add when a
|
||||
* multi-photo queue exists.
|
||||
*/
|
||||
private fun consumeSharedImage(ctx: Context, intent: Intent?): Map<String, String> {
|
||||
if (intent == null) return emptyMap()
|
||||
val action = intent.action
|
||||
if (action != Intent.ACTION_SEND && action != Intent.ACTION_SEND_MULTIPLE) return emptyMap()
|
||||
val uri = sharedStreamUri(intent) ?: return emptyMap()
|
||||
// Consumed: the activity keeps this intent for its whole life and JS re-asks
|
||||
// on every foreground return, so the extra goes — otherwise every resume
|
||||
// re-imported the same photo and threw the edits already made on it away.
|
||||
intent.removeExtra(Intent.EXTRA_STREAM)
|
||||
intent.clipData = null
|
||||
// The sender's declared type, not the file name: several apps share HEIC or
|
||||
// PNG streams with no usable name at all.
|
||||
val type = ctx.contentResolver.getType(uri) ?: intent.type ?: "image/*"
|
||||
val ext = when {
|
||||
type.contains("png") -> "png"
|
||||
type.contains("webp") -> "webp"
|
||||
type.contains("heic") || type.contains("heif") -> "heic"
|
||||
else -> "jpg"
|
||||
}
|
||||
val dst = File(File(ctx.cacheDir, "shared").apply { mkdirs() }, "shared-${System.currentTimeMillis()}.$ext")
|
||||
try {
|
||||
ctx.contentResolver.openInputStream(uri).use { input ->
|
||||
requireNotNull(input) { "unreadable share stream" }
|
||||
dst.outputStream().use { input.copyTo(it) }
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
Log.w(TAG, "shared image copy failed: ${e.message}")
|
||||
dst.delete()
|
||||
return emptyMap()
|
||||
}
|
||||
// MediaStore ids are the trailing number of the uri — "content://media/
|
||||
// external/images/media/12345", or the document form ".../image%3A12345"
|
||||
// (percent-decoded by Uri itself). The library pipeline uses it for the
|
||||
// MediaStore location lookup when the file carries no EXIF GPS.
|
||||
val id = (uri.lastPathSegment ?: "").substringAfterLast(':')
|
||||
val assetId = if (id.isNotEmpty() && id.all { c -> c.isDigit() }) id else ""
|
||||
Log.i(TAG, "shared image -> ${dst.absolutePath} (type=$type, assetId=$assetId)")
|
||||
return mapOf("path" to dst.absolutePath, "assetId" to assetId, "type" to type)
|
||||
}
|
||||
|
||||
/**
|
||||
* The sender's stream. Every API level puts it in EXTRA_STREAM — one Uri for
|
||||
* SEND, a ParcelableArrayList for SEND_MULTIPLE — and a few senders fill in
|
||||
* only clipData, so all three shapes are accepted.
|
||||
*/
|
||||
@Suppress("DEPRECATION")
|
||||
private fun sharedStreamUri(intent: Intent): Uri? {
|
||||
intent.getParcelableExtra<Uri>(Intent.EXTRA_STREAM)?.let { return it }
|
||||
intent.getParcelableArrayListExtra<Uri>(Intent.EXTRA_STREAM)?.firstOrNull()?.let { return it }
|
||||
val clip = intent.clipData
|
||||
return if (clip != null && clip.itemCount > 0) clip.getItemAt(0).uri else null
|
||||
}
|
||||
|
||||
@@ -42,7 +42,7 @@ export interface PhotoAdjust {
|
||||
toneSh?: number;
|
||||
/** 16 flat floats from cinemaShader.ts getCinemaUniforms(season).flat; null/omitted or flat[0]=0 = skip. */
|
||||
cinema?: number[] | null;
|
||||
/** ColorAdjustments.denoise 0..10 (0 = skip). */
|
||||
/** ColorAdjustments.denoise -10..10; >0 blur, <0 re-grain (0 = skip). */
|
||||
denoise?: number;
|
||||
/** ColorAdjustments.clarity -10..10; >0 conv sharpen, <0 mist blur (0 = skip). */
|
||||
clarity?: number;
|
||||
@@ -52,6 +52,8 @@ export interface PhotoAdjust {
|
||||
quality?: number;
|
||||
/** Final screen-sharpen amount (exportEngine #9 screenSharpenImage); >0 applies. */
|
||||
sharpen?: number;
|
||||
/** Soften blur radius for a negative sharpening knob (exportEngine #4); >0 applies. */
|
||||
soften?: number;
|
||||
/** DPI stamped into the JPEG header after encode (patchJpegDpi port); 0 = skip. */
|
||||
dpi?: number;
|
||||
/** Composition aspect-ratio crop computed after decode (center-largest, engine #1b). */
|
||||
@@ -104,6 +106,15 @@ export interface RecipescamExportModule {
|
||||
*/
|
||||
deviceInfo(): { device: string; marketName: string; manufacturer: string; model: string };
|
||||
|
||||
/**
|
||||
* The image a system share handed this launch (AndroidManifest
|
||||
* ACTION_SEND — see takeSharedImage in the Kotlin module): the sender's
|
||||
* stream is copied into the cache and returned as an ordinary file.
|
||||
* `path` is "" when this launch was not a share, or when the share was
|
||||
* already taken — the intent is read and cleared exactly once.
|
||||
*/
|
||||
takeSharedImage(): Promise<{ path: string; assetId: string; type: string }>;
|
||||
|
||||
/** P0 probe helpers — removed before ship. */
|
||||
ping(): Promise<string>;
|
||||
materializeBenchAsset(assetName: string): Promise<string>;
|
||||
|
||||
Reference in New Issue
Block a user