feat: v1.1 — highlight-weighted metering, settings modal, photo viewer, shutter sound control, cleanup
@@ -1,14 +0,0 @@
|
||||
> Why do I have a folder named ".expo" in my project?
|
||||
|
||||
The ".expo" folder is created when an Expo project is started using "expo start" command.
|
||||
|
||||
> What do the files contain?
|
||||
|
||||
- "devices.json": contains information about devices that have recently opened this project. This is used to populate the "Development sessions" list in your development builds.
|
||||
- "settings.json": contains the server configuration that is used to serve the application manifest.
|
||||
- "dev/logs/": contains structured JSONL event logs from CLI commands (e.g. start.log, export.log). These are truncated on each run.
|
||||
|
||||
> Should I commit the ".expo" folder?
|
||||
|
||||
No, you should not share the ".expo" folder. It does not contain any information that is relevant for other developers working on the project, it is specific to your machine.
|
||||
Upon project creation, the ".expo" folder is already added to your ".gitignore" file.
|
||||
@@ -1,412 +0,0 @@
|
||||
{"_e":"root:init","_t":1784254504055,"format":"v0-jsonl","version":"57.0.8"}
|
||||
{"_e":"env:mode","_t":1784254504056,"nodeEnv":"development","babelEnv":"development","mode":"development"}
|
||||
{"_e":"env:load","_t":1784254504058,"mode":"development","files":[],"env":{}}
|
||||
{"_e":"devserver:start","_t":1784254508949,"mode":"development","web":true,"baseUrl":"","asyncRoutes":false,"routerRoot":"../../../app","serverComponents":false,"serverActions":false,"serverRendering":false,"apiRoutes":false,"exporting":false}
|
||||
{"_e":"metro:config","_t":1784254509422,"serverRoot":"../../..","projectRoot":"../../..","exporting":false,"flags":{"autolinkingModuleResolution":false,"serverActions":false,"serverComponents":false,"reactCompiler":false,"optimizeGraph":false,"treeshaking":false,"logbox":false}}
|
||||
{"_e":"expo:dev-tools-plugin:load","_t":1784254509956,"plugins":[]}
|
||||
{"_e":"metro:instantiate","_t":1784254516842,"atlas":false,"workers":3,"host":"::","port":8086}
|
||||
{"_e":"metro:bundling:started","_t":1784254564715,"id":"1","platform":"android","environment":null,"entry":"index.ts"}
|
||||
{"_e":"metro:bundling:progress","_t":1784254564715,"id":"1","progress":0,"total":1,"current":0}
|
||||
{"_e":"metro:bundling:progress","_t":1784254564729,"id":"1","progress":0,"total":1,"current":0}
|
||||
{"_e":"metro:bundling:progress","_t":1784254565214,"id":"1","progress":0.010000000000000002,"total":1,"current":1}
|
||||
{"_e":"metro:bundling:progress","_t":1784254565318,"id":"1","progress":0.09,"total":9,"current":3}
|
||||
{"_e":"metro:bundling:progress","_t":1784254565418,"id":"1","progress":0.09,"total":41,"current":7}
|
||||
{"_e":"metro:bundling:progress","_t":1784254565518,"id":"1","progress":0.09,"total":46,"current":11}
|
||||
{"_e":"metro:bundling:progress","_t":1784254565577,"id":"1","progress":0.09,"total":46,"current":12}
|
||||
{"_e":"metro:bundling:progress","_t":1784254565690,"id":"1","progress":0.09,"total":147,"current":14}
|
||||
{"_e":"metro:bundling:progress","_t":1784254565794,"id":"1","progress":0.09,"total":156,"current":24}
|
||||
{"_e":"metro:bundling:progress","_t":1784254565894,"id":"1","progress":0.09,"total":157,"current":31}
|
||||
{"_e":"metro:bundling:progress","_t":1784254565998,"id":"1","progress":0.09,"total":185,"current":45}
|
||||
{"_e":"metro:bundling:progress","_t":1784254566099,"id":"1","progress":0.09,"total":197,"current":52}
|
||||
{"_e":"metro:bundling:progress","_t":1784254566211,"id":"1","progress":0.09,"total":210,"current":59}
|
||||
{"_e":"metro:bundling:progress","_t":1784254566311,"id":"1","progress":0.09,"total":223,"current":66}
|
||||
{"_e":"metro:bundling:progress","_t":1784254566419,"id":"1","progress":0.09782840730462457,"total":227,"current":71}
|
||||
{"_e":"metro:bundling:progress","_t":1784254566519,"id":"1","progress":0.11201999167013746,"total":245,"current":82}
|
||||
{"_e":"metro:bundling:progress","_t":1784254566623,"id":"1","progress":0.15123456790123457,"total":270,"current":105}
|
||||
{"_e":"metro:bundling:progress","_t":1784254566723,"id":"1","progress":0.18367346938775508,"total":287,"current":123}
|
||||
{"_e":"metro:bundling:progress","_t":1784254566823,"id":"1","progress":0.2352733413538482,"total":301,"current":146}
|
||||
{"_e":"metro:bundling:progress","_t":1784254566923,"id":"1","progress":0.259462890625,"total":320,"current":163}
|
||||
{"_e":"metro:bundling:progress","_t":1784254567024,"id":"1","progress":0.27952464836940155,"total":331,"current":175}
|
||||
{"_e":"metro:bundling:progress","_t":1784254567124,"id":"1","progress":0.33271161520688924,"total":345,"current":199}
|
||||
{"_e":"metro:bundling:progress","_t":1784254567225,"id":"1","progress":0.35676406135865596,"total":370,"current":221}
|
||||
{"_e":"metro:bundling:progress","_t":1784254567326,"id":"1","progress":0.37024645369120834,"total":401,"current":244}
|
||||
{"_e":"metro:bundling:progress","_t":1784254567428,"id":"1","progress":0.37472705039741466,"total":428,"current":262}
|
||||
{"_e":"metro:bundling:progress","_t":1784254567529,"id":"1","progress":0.3978698224852071,"total":455,"current":287}
|
||||
{"_e":"metro:bundling:progress","_t":1784254567628,"id":"1","progress":0.41077662667090886,"total":479,"current":307}
|
||||
{"_e":"metro:bundling:progress","_t":1784254567730,"id":"1","progress":0.42145226326742974,"total":496,"current":322}
|
||||
{"_e":"metro:bundling:progress","_t":1784254567834,"id":"1","progress":0.42145226326742974,"total":536,"current":333}
|
||||
{"_e":"metro:bundling:progress","_t":1784254567938,"id":"1","progress":0.42145226326742974,"total":540,"current":339}
|
||||
{"_e":"metro:bundling:progress","_t":1784254568057,"id":"1","progress":0.42145226326742974,"total":543,"current":345}
|
||||
{"_e":"metro:bundling:progress","_t":1784254568158,"id":"1","progress":0.42145226326742974,"total":556,"current":354}
|
||||
{"_e":"metro:bundling:progress","_t":1784254568257,"id":"1","progress":0.42145226326742974,"total":556,"current":356}
|
||||
{"_e":"metro:bundling:progress","_t":1784254568357,"id":"1","progress":0.42472014414228576,"total":557,"current":363}
|
||||
{"_e":"metro:bundling:progress","_t":1784254568458,"id":"1","progress":0.42472014414228576,"total":571,"current":370}
|
||||
{"_e":"metro:bundling:progress","_t":1784254568559,"id":"1","progress":0.44212055590581395,"total":573,"current":381}
|
||||
{"_e":"metro:bundling:progress","_t":1784254568663,"id":"1","progress":0.46466158211707237,"total":578,"current":394}
|
||||
{"_e":"metro:bundling:progress","_t":1784254568764,"id":"1","progress":0.48831470460153875,"total":581,"current":406}
|
||||
{"_e":"metro:bundling:progress","_t":1784254568909,"id":"1","progress":0.500828402366864,"total":585,"current":414}
|
||||
{"_e":"metro:bundling:progress","_t":1784254569015,"id":"1","progress":0.500828402366864,"total":2334,"current":415}
|
||||
{"_e":"metro:bundling:progress","_t":1784254569115,"id":"1","progress":0.500828402366864,"total":2338,"current":435}
|
||||
{"_e":"metro:bundling:progress","_t":1784254569222,"id":"1","progress":0.500828402366864,"total":2343,"current":440}
|
||||
{"_e":"metro:bundling:progress","_t":1784254569326,"id":"1","progress":0.500828402366864,"total":2346,"current":459}
|
||||
{"_e":"metro:bundling:progress","_t":1784254569467,"id":"1","progress":0.500828402366864,"total":2347,"current":467}
|
||||
{"_e":"metro:bundling:progress","_t":1784254569567,"id":"1","progress":0.500828402366864,"total":2348,"current":472}
|
||||
{"_e":"metro:bundling:progress","_t":1784254569667,"id":"1","progress":0.500828402366864,"total":2367,"current":491}
|
||||
{"_e":"metro:bundling:progress","_t":1784254569771,"id":"1","progress":0.500828402366864,"total":2428,"current":523}
|
||||
{"_e":"metro:bundling:progress","_t":1784254569871,"id":"1","progress":0.500828402366864,"total":2454,"current":542}
|
||||
{"_e":"metro:bundling:progress","_t":1784254569971,"id":"1","progress":0.500828402366864,"total":2465,"current":563}
|
||||
{"_e":"metro:bundling:progress","_t":1784254570071,"id":"1","progress":0.500828402366864,"total":2477,"current":583}
|
||||
{"_e":"metro:bundling:progress","_t":1784254570173,"id":"1","progress":0.500828402366864,"total":2478,"current":625}
|
||||
{"_e":"metro:bundling:progress","_t":1784254570273,"id":"1","progress":0.500828402366864,"total":2478,"current":677}
|
||||
{"_e":"metro:bundling:progress","_t":1784254570374,"id":"1","progress":0.500828402366864,"total":2478,"current":727}
|
||||
{"_e":"metro:bundling:progress","_t":1784254570474,"id":"1","progress":0.500828402366864,"total":2478,"current":772}
|
||||
{"_e":"metro:bundling:progress","_t":1784254570574,"id":"1","progress":0.500828402366864,"total":2478,"current":818}
|
||||
{"_e":"metro:bundling:progress","_t":1784254570674,"id":"1","progress":0.500828402366864,"total":2478,"current":863}
|
||||
{"_e":"metro:bundling:progress","_t":1784254570774,"id":"1","progress":0.500828402366864,"total":2478,"current":912}
|
||||
{"_e":"metro:bundling:progress","_t":1784254570874,"id":"1","progress":0.500828402366864,"total":2478,"current":965}
|
||||
{"_e":"metro:bundling:progress","_t":1784254570974,"id":"1","progress":0.500828402366864,"total":2478,"current":1008}
|
||||
{"_e":"metro:bundling:progress","_t":1784254571076,"id":"1","progress":0.500828402366864,"total":2478,"current":1056}
|
||||
{"_e":"metro:bundling:progress","_t":1784254571176,"id":"1","progress":0.500828402366864,"total":2478,"current":1105}
|
||||
{"_e":"metro:bundling:progress","_t":1784254571277,"id":"1","progress":0.500828402366864,"total":2478,"current":1160}
|
||||
{"_e":"metro:bundling:progress","_t":1784254571377,"id":"1","progress":0.500828402366864,"total":2478,"current":1209}
|
||||
{"_e":"metro:bundling:progress","_t":1784254571478,"id":"1","progress":0.500828402366864,"total":2478,"current":1264}
|
||||
{"_e":"metro:bundling:progress","_t":1784254571578,"id":"1","progress":0.500828402366864,"total":2478,"current":1311}
|
||||
{"_e":"metro:bundling:progress","_t":1784254571678,"id":"1","progress":0.500828402366864,"total":2478,"current":1357}
|
||||
{"_e":"metro:bundling:progress","_t":1784254571778,"id":"1","progress":0.500828402366864,"total":2478,"current":1410}
|
||||
{"_e":"metro:bundling:progress","_t":1784254571878,"id":"1","progress":0.500828402366864,"total":2478,"current":1461}
|
||||
{"_e":"metro:bundling:progress","_t":1784254571978,"id":"1","progress":0.500828402366864,"total":2478,"current":1507}
|
||||
{"_e":"metro:bundling:progress","_t":1784254572078,"id":"1","progress":0.500828402366864,"total":2478,"current":1560}
|
||||
{"_e":"metro:bundling:progress","_t":1784254572178,"id":"1","progress":0.500828402366864,"total":2478,"current":1611}
|
||||
{"_e":"metro:bundling:progress","_t":1784254572279,"id":"1","progress":0.500828402366864,"total":2478,"current":1668}
|
||||
{"_e":"metro:bundling:progress","_t":1784254572379,"id":"1","progress":0.500828402366864,"total":2478,"current":1721}
|
||||
{"_e":"metro:bundling:progress","_t":1784254572480,"id":"1","progress":0.5136689550856253,"total":2478,"current":1776}
|
||||
{"_e":"metro:bundling:progress","_t":1784254572580,"id":"1","progress":0.5394363050209071,"total":2478,"current":1820}
|
||||
{"_e":"metro:bundling:progress","_t":1784254572681,"id":"1","progress":0.5737542838642687,"total":2478,"current":1877}
|
||||
{"_e":"metro:bundling:progress","_t":1784254572781,"id":"1","progress":0.6047290409029646,"total":2478,"current":1927}
|
||||
{"_e":"metro:bundling:progress","_t":1784254572881,"id":"1","progress":0.6397417532559323,"total":2478,"current":1982}
|
||||
{"_e":"metro:bundling:progress","_t":1784254572981,"id":"1","progress":0.6737507987969679,"total":2478,"current":2034}
|
||||
{"_e":"metro:bundling:progress","_t":1784254573081,"id":"1","progress":0.7106802981654216,"total":2478,"current":2089}
|
||||
{"_e":"metro:bundling:progress","_t":1784254573181,"id":"1","progress":0.7506914764373623,"total":2478,"current":2147}
|
||||
{"_e":"metro:bundling:progress","_t":1784254573281,"id":"1","progress":0.782489458485683,"total":2478,"current":2192}
|
||||
{"_e":"metro:bundling:progress","_t":1784254573382,"id":"1","progress":0.822981380620811,"total":2478,"current":2248}
|
||||
{"_e":"metro:bundling:progress","_t":1784254573483,"id":"1","progress":0.8637444540202369,"total":2478,"current":2303}
|
||||
{"_e":"metro:bundling:progress","_t":1784254573583,"id":"1","progress":0.8846124232278917,"total":2489,"current":2341}
|
||||
{"_e":"metro:bundling:progress","_t":1784254573683,"id":"1","progress":0.8880743173619988,"total":2499,"current":2355}
|
||||
{"_e":"metro:bundling:progress","_t":1784254573786,"id":"1","progress":0.8880743173619988,"total":2589,"current":2373}
|
||||
{"_e":"metro:bundling:progress","_t":1784254573886,"id":"1","progress":0.8880743173619988,"total":2601,"current":2399}
|
||||
{"_e":"metro:bundling:progress","_t":1784254573987,"id":"1","progress":0.8880743173619988,"total":2602,"current":2410}
|
||||
{"_e":"metro:bundling:progress","_t":1784254574087,"id":"1","progress":0.8880743173619988,"total":2614,"current":2424}
|
||||
{"_e":"metro:bundling:progress","_t":1784254574188,"id":"1","progress":0.8880743173619988,"total":2617,"current":2444}
|
||||
{"_e":"metro:bundling:progress","_t":1784254574288,"id":"1","progress":0.8880743173619988,"total":2619,"current":2451}
|
||||
{"_e":"metro:bundling:progress","_t":1784254574388,"id":"1","progress":0.8880743173619988,"total":2621,"current":2455}
|
||||
{"_e":"metro:bundling:progress","_t":1784254574488,"id":"1","progress":0.8880743173619988,"total":2625,"current":2462}
|
||||
{"_e":"metro:bundling:progress","_t":1784254574588,"id":"1","progress":0.8880743173619988,"total":2631,"current":2469}
|
||||
{"_e":"metro:bundling:progress","_t":1784254574688,"id":"1","progress":0.8880743173619988,"total":2636,"current":2474}
|
||||
{"_e":"metro:bundling:progress","_t":1784254574844,"id":"1","progress":0.8880743173619988,"total":2640,"current":2481}
|
||||
{"_e":"metro:bundling:progress","_t":1784254574944,"id":"1","progress":0.8880743173619988,"total":2649,"current":2493}
|
||||
{"_e":"metro:bundling:progress","_t":1784254575044,"id":"1","progress":0.8880743173619988,"total":2700,"current":2508}
|
||||
{"_e":"metro:bundling:progress","_t":1784254575144,"id":"1","progress":0.8880743173619988,"total":2707,"current":2526}
|
||||
{"_e":"metro:bundling:progress","_t":1784254575245,"id":"1","progress":0.8880743173619988,"total":2713,"current":2534}
|
||||
{"_e":"metro:bundling:progress","_t":1784254575345,"id":"1","progress":0.8880743173619988,"total":2778,"current":2556}
|
||||
{"_e":"metro:bundling:progress","_t":1784254575447,"id":"1","progress":0.8880743173619988,"total":2786,"current":2569}
|
||||
{"_e":"metro:bundling:progress","_t":1784254575547,"id":"1","progress":0.8880743173619988,"total":2787,"current":2572}
|
||||
{"_e":"metro:bundling:progress","_t":1784254575647,"id":"1","progress":0.8880743173619988,"total":2787,"current":2574}
|
||||
{"_e":"metro:bundling:progress","_t":1784254575778,"id":"1","progress":0.8880743173619988,"total":2787,"current":2575}
|
||||
{"_e":"metro:bundling:progress","_t":1784254575878,"id":"1","progress":0.8880743173619988,"total":2789,"current":2582}
|
||||
{"_e":"metro:bundling:progress","_t":1784254575980,"id":"1","progress":0.8880743173619988,"total":2789,"current":2593}
|
||||
{"_e":"metro:bundling:progress","_t":1784254576082,"id":"1","progress":0.8880743173619988,"total":2799,"current":2612}
|
||||
{"_e":"metro:bundling:progress","_t":1784254576182,"id":"1","progress":0.8880743173619988,"total":2813,"current":2637}
|
||||
{"_e":"metro:bundling:progress","_t":1784254576282,"id":"1","progress":0.8880743173619988,"total":2824,"current":2661}
|
||||
{"_e":"metro:bundling:progress","_t":1784254576384,"id":"1","progress":0.9156393512217658,"total":2830,"current":2708}
|
||||
{"_e":"metro:bundling:progress","_t":1784254576484,"id":"1","progress":0.9156393512217658,"total":2848,"current":2724}
|
||||
{"_e":"metro:bundling:progress","_t":1784254576584,"id":"1","progress":0.9156393512217658,"total":2849,"current":2726}
|
||||
{"_e":"metro:bundling:progress","_t":1784254576684,"id":"1","progress":0.9156393512217658,"total":2868,"current":2734}
|
||||
{"_e":"metro:bundling:progress","_t":1784254576827,"id":"1","progress":0.9156393512217658,"total":2868,"current":2735}
|
||||
{"_e":"metro:bundling:progress","_t":1784254576927,"id":"1","progress":0.9156393512217658,"total":2885,"current":2738}
|
||||
{"_e":"metro:bundling:progress","_t":1784254577029,"id":"1","progress":0.9156393512217658,"total":2885,"current":2741}
|
||||
{"_e":"metro:bundling:progress","_t":1784254577129,"id":"1","progress":0.9156393512217658,"total":2900,"current":2760}
|
||||
{"_e":"metro:bundling:progress","_t":1784254577229,"id":"1","progress":0.9242757760075363,"total":2901,"current":2789}
|
||||
{"_e":"metro:bundling:progress","_t":1784254577332,"id":"1","progress":0.936287891094404,"total":2903,"current":2809}
|
||||
{"_e":"metro:bundling:progress","_t":1784254577435,"id":"1","progress":0.936287891094404,"total":2938,"current":2819}
|
||||
{"_e":"metro:bundling:progress","_t":1784254577540,"id":"1","progress":0.936287891094404,"total":2986,"current":2825}
|
||||
{"_e":"metro:bundling:progress","_t":1784254577642,"id":"1","progress":0.936287891094404,"total":3015,"current":2842}
|
||||
{"_e":"metro:bundling:progress","_t":1784254577740,"id":"1","progress":0.936287891094404,"total":3030,"current":2856}
|
||||
{"_e":"metro:bundling:progress","_t":1784254577840,"id":"1","progress":0.936287891094404,"total":3035,"current":2860}
|
||||
{"_e":"metro:bundling:progress","_t":1784254577940,"id":"1","progress":0.936287891094404,"total":3044,"current":2871}
|
||||
{"_e":"metro:bundling:progress","_t":1784254578041,"id":"1","progress":0.936287891094404,"total":3047,"current":2879}
|
||||
{"_e":"metro:bundling:progress","_t":1784254578141,"id":"1","progress":0.936287891094404,"total":3065,"current":2890}
|
||||
{"_e":"metro:bundling:progress","_t":1784254578241,"id":"1","progress":0.936287891094404,"total":3080,"current":2900}
|
||||
{"_e":"metro:bundling:progress","_t":1784254578342,"id":"1","progress":0.936287891094404,"total":3082,"current":2913}
|
||||
{"_e":"metro:bundling:progress","_t":1784254578442,"id":"1","progress":0.936287891094404,"total":3082,"current":2928}
|
||||
{"_e":"metro:bundling:progress","_t":1784254578543,"id":"1","progress":0.936287891094404,"total":3089,"current":2949}
|
||||
{"_e":"metro:bundling:progress","_t":1784254578642,"id":"1","progress":0.936287891094404,"total":3096,"current":2973}
|
||||
{"_e":"metro:bundling:progress","_t":1784254578742,"id":"1","progress":0.936287891094404,"total":3114,"current":2999}
|
||||
{"_e":"metro:bundling:progress","_t":1784254578843,"id":"1","progress":0.936287891094404,"total":3114,"current":3002}
|
||||
{"_e":"metro:bundling:progress","_t":1784254578943,"id":"1","progress":0.936287891094404,"total":3123,"current":3009}
|
||||
{"_e":"metro:bundling:progress","_t":1784254579045,"id":"1","progress":0.936287891094404,"total":3123,"current":3011}
|
||||
{"_e":"metro:bundling:progress","_t":1784254579064,"id":"1","progress":0.936287891094404,"total":3123,"current":3012}
|
||||
{"_e":"metro:bundling:progress","_t":1784254579164,"id":"1","progress":0.936287891094404,"total":3131,"current":3025}
|
||||
{"_e":"metro:bundling:progress","_t":1784254579265,"id":"1","progress":0.9365842200881371,"total":3134,"current":3033}
|
||||
{"_e":"metro:bundling:progress","_t":1784254579367,"id":"1","progress":0.9368419128824654,"total":3147,"current":3046}
|
||||
{"_e":"metro:bundling:progress","_t":1784254579468,"id":"1","progress":0.9387456790123457,"total":3150,"current":3052}
|
||||
{"_e":"metro:bundling:progress","_t":1784254579568,"id":"1","progress":0.9393609473418997,"total":3150,"current":3053}
|
||||
{"_e":"metro:bundling:progress","_t":1784254579682,"id":"1","progress":0.9412079617031998,"total":3150,"current":3056}
|
||||
{"_e":"metro:bundling:progress","_t":1784254579782,"id":"1","progress":0.9430567901234569,"total":3150,"current":3059}
|
||||
{"_e":"metro:bundling:progress","_t":1784254579883,"id":"1","progress":0.9473777777777779,"total":3150,"current":3066}
|
||||
{"_e":"metro:bundling:progress","_t":1784254579989,"id":"1","progress":0.9529773417570666,"total":3152,"current":3077}
|
||||
{"_e":"metro:bundling:progress","_t":1784254580089,"id":"1","progress":0.9549354307189066,"total":3159,"current":3087}
|
||||
{"_e":"metro:bundling:progress","_t":1784254580189,"id":"1","progress":0.9686047908988945,"total":3160,"current":3110}
|
||||
{"_e":"metro:bundling:progress","_t":1784254580292,"id":"1","progress":0.9717926782024794,"total":3168,"current":3123}
|
||||
{"_e":"metro:bundling:progress","_t":1784254580392,"id":"1","progress":0.9767778200247423,"total":3168,"current":3131}
|
||||
{"_e":"metro:bundling:progress","_t":1784254580502,"id":"1","progress":0.9768938323842832,"total":3184,"current":3147}
|
||||
{"_e":"metro:bundling:progress","_t":1784254580608,"id":"1","progress":0.9769943965134558,"total":3198,"current":3161}
|
||||
{"_e":"metro:bundling:progress","_t":1784254580709,"id":"1","progress":0.9769943965134558,"total":3220,"current":3182}
|
||||
{"_e":"metro:bundling:progress","_t":1784254580809,"id":"1","progress":0.9791352776121244,"total":3242,"current":3208}
|
||||
{"_e":"metro:bundling:progress","_t":1784254580909,"id":"1","progress":0.9791352776121244,"total":3272,"current":3232}
|
||||
{"_e":"metro:bundling:progress","_t":1784254581010,"id":"1","progress":0.9848326177487721,"total":3284,"current":3259}
|
||||
{"_e":"metro:bundling:progress","_t":1784254581111,"id":"1","progress":0.999,"total":3291,"current":3290}
|
||||
{"_e":"metro:bundling:progress","_t":1784254581211,"id":"1","progress":0.999,"total":3291,"current":3291}
|
||||
{"_e":"metro:bundling:done","_t":1784254581468,"id":"1","total":3291,"ms":16753.428873}
|
||||
{"_e":"metro:client_log","_t":1784254596888,"level":"warn","data":["SafeAreaView has been deprecated and will be removed in a future release. Please use 'react-native-safe-area-context' instead. See https://github.com/AppAndFlow/react-native-safe-area-context"]}
|
||||
{"_e":"metro:bundling:started","_t":1784254612886,"id":"3","platform":"android","environment":null,"entry":"index.ts"}
|
||||
{"_e":"metro:bundling:progress","_t":1784254612886,"id":"3","progress":0,"total":1,"current":0}
|
||||
{"_e":"metro:bundling:done","_t":1784254612993,"id":"3","total":1,"ms":107.061183}
|
||||
{"_e":"metro:client_log","_t":1784254630240,"level":"warn","data":["SafeAreaView has been deprecated and will be removed in a future release. Please use 'react-native-safe-area-context' instead. See https://github.com/AppAndFlow/react-native-safe-area-context"]}
|
||||
{"_e":"metro:client_log","_t":1784254721520,"level":"warn","data":["[expo-image-picker] `ImagePicker.MediaTypeOptions` have been deprecated. Use `ImagePicker.MediaType` or an array of `ImagePicker.MediaType` instead."]}
|
||||
{"_e":"metro:client_log","_t":1784254774528,"level":"warn","data":["[expo-image-picker] `ImagePicker.MediaTypeOptions` have been deprecated. Use `ImagePicker.MediaType` or an array of `ImagePicker.MediaType` instead."]}
|
||||
{"_e":"metro:client_log","_t":1784254928412,"level":"warn","data":["[expo-image-picker] `ImagePicker.MediaTypeOptions` have been deprecated. Use `ImagePicker.MediaType` or an array of `ImagePicker.MediaType` instead."]}
|
||||
{"_e":"metro:bundling:started","_t":1784256200326,"id":"7","platform":"android","environment":null,"entry":"index.ts"}
|
||||
{"_e":"metro:bundling:progress","_t":1784256200326,"id":"7","progress":0,"total":1,"current":0}
|
||||
{"_e":"metro:bundling:progress","_t":1784256200332,"id":"7","progress":0,"total":1,"current":0}
|
||||
{"_e":"metro:bundling:progress","_t":1784256200434,"id":"7","progress":0,"total":1,"current":0}
|
||||
{"_e":"metro:bundling:done","_t":1784256200538,"id":"7","total":1,"ms":211.987279}
|
||||
{"_e":"metro:client_log","_t":1784256223781,"level":"warn","data":["SafeAreaView has been deprecated and will be removed in a future release. Please use 'react-native-safe-area-context' instead. See https://github.com/AppAndFlow/react-native-safe-area-context"]}
|
||||
{"_e":"metro:client_log","_t":1784256310429,"level":"warn","data":["[expo-image-picker] `ImagePicker.MediaTypeOptions` have been deprecated. Use `ImagePicker.MediaType` or an array of `ImagePicker.MediaType` instead."]}
|
||||
{"_e":"metro:client_log","_t":1784256369062,"level":"warn","data":["[expo-image-picker] `ImagePicker.MediaTypeOptions` have been deprecated. Use `ImagePicker.MediaType` or an array of `ImagePicker.MediaType` instead."]}
|
||||
{"_e":"metro:client_log","_t":1784256387012,"level":"warn","data":["[expo-image-picker] `ImagePicker.MediaTypeOptions` have been deprecated. Use `ImagePicker.MediaType` or an array of `ImagePicker.MediaType` instead."]}
|
||||
{"_e":"metro:client_log","_t":1784256420271,"level":"warn","data":["[expo-image-picker] `ImagePicker.MediaTypeOptions` have been deprecated. Use `ImagePicker.MediaType` or an array of `ImagePicker.MediaType` instead."]}
|
||||
{"_e":"metro:bundling:started","_t":1784258596497,"id":"9","platform":"android","environment":null,"entry":"index.ts"}
|
||||
{"_e":"metro:bundling:progress","_t":1784258596520,"id":"9","progress":0,"total":1,"current":0}
|
||||
{"_e":"metro:bundling:progress","_t":1784258596532,"id":"9","progress":0,"total":1,"current":0}
|
||||
{"_e":"metro:bundling:progress","_t":1784258596843,"id":"9","progress":0,"total":1,"current":0}
|
||||
{"_e":"metro:bundling:progress","_t":1784258597094,"id":"9","progress":0,"total":1,"current":0}
|
||||
{"_e":"metro:bundling:progress","_t":1784258597194,"id":"9","progress":0,"total":1,"current":0}
|
||||
{"_e":"metro:bundling:progress","_t":1784258597294,"id":"9","progress":0,"total":1,"current":0}
|
||||
{"_e":"metro:bundling:progress","_t":1784258597394,"id":"9","progress":0,"total":1,"current":0}
|
||||
{"_e":"metro:bundling:progress","_t":1784258597494,"id":"9","progress":0,"total":1,"current":0}
|
||||
{"_e":"metro:bundling:progress","_t":1784258597594,"id":"9","progress":0,"total":1,"current":0}
|
||||
{"_e":"metro:bundling:progress","_t":1784258597694,"id":"9","progress":0,"total":1,"current":0}
|
||||
{"_e":"metro:bundling:progress","_t":1784258597809,"id":"9","progress":0,"total":1,"current":0}
|
||||
{"_e":"metro:bundling:progress","_t":1784258597925,"id":"9","progress":0,"total":1,"current":0}
|
||||
{"_e":"metro:bundling:progress","_t":1784258598025,"id":"9","progress":0,"total":1,"current":0}
|
||||
{"_e":"metro:bundling:done","_t":1784258598321,"id":"9","total":1,"ms":1802.095472}
|
||||
{"_e":"metro:client_log","_t":1784258647493,"level":"warn","data":["[expo-image-picker] `ImagePicker.MediaTypeOptions` have been deprecated. Use `ImagePicker.MediaType` or an array of `ImagePicker.MediaType` instead."]}
|
||||
{"_e":"metro:bundling:started","_t":1784261637702,"id":"b","platform":"android","environment":null,"entry":"index.ts"}
|
||||
{"_e":"metro:bundling:progress","_t":1784261637702,"id":"b","progress":0,"total":1,"current":0}
|
||||
{"_e":"metro:bundling:progress","_t":1784261637708,"id":"b","progress":0,"total":1,"current":0}
|
||||
{"_e":"metro:bundling:progress","_t":1784261637808,"id":"b","progress":0,"total":1,"current":0}
|
||||
{"_e":"metro:bundling:progress","_t":1784261638016,"id":"b","progress":0,"total":1,"current":0}
|
||||
{"_e":"metro:bundling:progress","_t":1784261638197,"id":"b","progress":0,"total":1,"current":0}
|
||||
{"_e":"metro:bundling:done","_t":1784261638447,"id":"b","total":1,"ms":744.986372}
|
||||
{"_e":"metro:client_log","_t":1784261670156,"level":"warn","data":["[expo-image-picker] `ImagePicker.MediaTypeOptions` have been deprecated. Use `ImagePicker.MediaType` or an array of `ImagePicker.MediaType` instead."]}
|
||||
{"_e":"metro:client_log","_t":1784261697357,"level":"warn","data":["[expo-image-picker] `ImagePicker.MediaTypeOptions` have been deprecated. Use `ImagePicker.MediaType` or an array of `ImagePicker.MediaType` instead."]}
|
||||
{"_e":"metro:bundling:started","_t":1784263415011,"id":"d","platform":"android","environment":null,"entry":"index.ts"}
|
||||
{"_e":"metro:bundling:progress","_t":1784263415013,"id":"d","progress":0,"total":1,"current":0}
|
||||
{"_e":"metro:bundling:progress","_t":1784263415016,"id":"d","progress":0,"total":1,"current":0}
|
||||
{"_e":"metro:bundling:progress","_t":1784263415116,"id":"d","progress":0,"total":1,"current":0}
|
||||
{"_e":"metro:bundling:progress","_t":1784263415858,"id":"d","progress":0,"total":1,"current":0}
|
||||
{"_e":"metro:bundling:done","_t":1784263416167,"id":"d","total":1,"ms":1153.731646}
|
||||
{"_e":"metro:bundling:started","_t":1784266840468,"id":"f","platform":"android","environment":null,"entry":"index.ts"}
|
||||
{"_e":"metro:bundling:progress","_t":1784266840469,"id":"f","progress":0,"total":1,"current":0}
|
||||
{"_e":"metro:bundling:progress","_t":1784266840484,"id":"f","progress":0,"total":1,"current":0}
|
||||
{"_e":"metro:bundling:progress","_t":1784266840743,"id":"f","progress":0,"total":1,"current":0}
|
||||
{"_e":"metro:bundling:progress","_t":1784266840992,"id":"f","progress":0,"total":1,"current":0}
|
||||
{"_e":"metro:bundling:progress","_t":1784266841361,"id":"f","progress":0,"total":1,"current":0}
|
||||
{"_e":"metro:bundling:progress","_t":1784266841461,"id":"f","progress":0,"total":1,"current":0}
|
||||
{"_e":"metro:bundling:progress","_t":1784266841561,"id":"f","progress":0,"total":1,"current":0}
|
||||
{"_e":"metro:bundling:progress","_t":1784266841661,"id":"f","progress":0,"total":1,"current":0}
|
||||
{"_e":"metro:bundling:progress","_t":1784266841761,"id":"f","progress":0,"total":1,"current":0}
|
||||
{"_e":"metro:bundling:progress","_t":1784266841861,"id":"f","progress":0,"total":1,"current":0}
|
||||
{"_e":"metro:bundling:progress","_t":1784266841961,"id":"f","progress":0,"total":1,"current":0}
|
||||
{"_e":"metro:bundling:progress","_t":1784266842061,"id":"f","progress":0,"total":1,"current":0}
|
||||
{"_e":"metro:bundling:progress","_t":1784266842173,"id":"f","progress":0,"total":1,"current":0}
|
||||
{"_e":"metro:bundling:progress","_t":1784266842275,"id":"f","progress":0,"total":1,"current":0}
|
||||
{"_e":"metro:bundling:progress","_t":1784266842846,"id":"f","progress":0,"total":1,"current":0}
|
||||
{"_e":"metro:bundling:progress","_t":1784266843171,"id":"f","progress":0,"total":1,"current":0}
|
||||
{"_e":"metro:bundling:progress","_t":1784266843304,"id":"f","progress":0,"total":1,"current":0}
|
||||
{"_e":"metro:bundling:progress","_t":1784266843413,"id":"f","progress":0,"total":1,"current":0}
|
||||
{"_e":"metro:bundling:progress","_t":1784266844439,"id":"f","progress":0,"total":1,"current":0}
|
||||
{"_e":"metro:bundling:progress","_t":1784266844538,"id":"f","progress":0,"total":1,"current":0}
|
||||
{"_e":"metro:bundling:progress","_t":1784266844818,"id":"f","progress":0,"total":1,"current":0}
|
||||
{"_e":"metro:bundling:progress","_t":1784266844918,"id":"f","progress":0,"total":1,"current":0}
|
||||
{"_e":"metro:bundling:progress","_t":1784266844939,"id":"f","progress":0,"total":1,"current":0}
|
||||
{"_e":"metro:bundling:progress","_t":1784266845040,"id":"f","progress":0,"total":1,"current":0}
|
||||
{"_e":"metro:bundling:progress","_t":1784266845141,"id":"f","progress":0,"total":1,"current":0}
|
||||
{"_e":"metro:bundling:progress","_t":1784266845241,"id":"f","progress":0,"total":1,"current":0}
|
||||
{"_e":"metro:bundling:progress","_t":1784266845341,"id":"f","progress":0,"total":1,"current":0}
|
||||
{"_e":"metro:bundling:progress","_t":1784266845443,"id":"f","progress":0,"total":1,"current":0}
|
||||
{"_e":"metro:bundling:progress","_t":1784266845546,"id":"f","progress":0,"total":1,"current":0}
|
||||
{"_e":"metro:bundling:progress","_t":1784266845580,"id":"f","progress":0,"total":1,"current":0}
|
||||
{"_e":"metro:bundling:done","_t":1784266846113,"id":"f","total":1,"ms":5644.099564}
|
||||
{"_e":"metro:client_log","_t":1784266851912,"level":"error","data":["[TypeError: undefined is not a function]"]}
|
||||
{"_e":"root:init","_t":1784293044660,"format":"v0-jsonl","version":"57.0.8"}
|
||||
{"_e":"env:mode","_t":1784293044688,"nodeEnv":"development","babelEnv":"development","mode":"development"}
|
||||
{"_e":"env:load","_t":1784293044699,"mode":"development","files":[],"env":{}}
|
||||
{"_e":"devserver:start","_t":1784293048866,"mode":"development","web":true,"baseUrl":"","asyncRoutes":false,"routerRoot":"../../../app","serverComponents":false,"serverActions":false,"serverRendering":false,"apiRoutes":false,"exporting":false}
|
||||
{"_e":"metro:config","_t":1784293054547,"serverRoot":"../../..","projectRoot":"../../..","exporting":false,"flags":{"autolinkingModuleResolution":false,"serverActions":false,"serverComponents":false,"reactCompiler":false,"optimizeGraph":false,"treeshaking":false,"logbox":false}}
|
||||
{"_e":"expo:dev-tools-plugin:load","_t":1784293056345,"plugins":[]}
|
||||
{"_e":"metro:instantiate","_t":1784293066659,"atlas":false,"workers":6,"host":"::","port":8081}
|
||||
{"_e":"metro:bundling:started","_t":1784293282277,"id":"1","platform":"android","environment":null,"entry":"index.ts"}
|
||||
{"_e":"metro:bundling:progress","_t":1784293282278,"id":"1","progress":0,"total":1,"current":0}
|
||||
{"_e":"metro:bundling:progress","_t":1784293282305,"id":"1","progress":0,"total":1,"current":0}
|
||||
{"_e":"metro:bundling:progress","_t":1784293284527,"id":"1","progress":0.010000000000000002,"total":1,"current":1}
|
||||
{"_e":"metro:bundling:progress","_t":1784293284627,"id":"1","progress":0.09,"total":9,"current":3}
|
||||
{"_e":"metro:bundling:progress","_t":1784293284739,"id":"1","progress":0.25,"total":12,"current":6}
|
||||
{"_e":"metro:bundling:progress","_t":1784293284907,"id":"1","progress":0.34027777777777785,"total":12,"current":7}
|
||||
{"_e":"metro:bundling:progress","_t":1784293285240,"id":"1","progress":0.4444444444444444,"total":12,"current":8}
|
||||
{"_e":"metro:bundling:progress","_t":1784293285340,"id":"1","progress":0.4444444444444444,"total":60,"current":12}
|
||||
{"_e":"metro:bundling:progress","_t":1784293285447,"id":"1","progress":0.4444444444444444,"total":60,"current":13}
|
||||
{"_e":"metro:bundling:progress","_t":1784293285550,"id":"1","progress":0.4444444444444444,"total":65,"current":19}
|
||||
{"_e":"metro:bundling:progress","_t":1784293285649,"id":"1","progress":0.4444444444444444,"total":77,"current":41}
|
||||
{"_e":"metro:bundling:progress","_t":1784293285750,"id":"1","progress":0.4444444444444444,"total":94,"current":52}
|
||||
{"_e":"metro:bundling:progress","_t":1784293285850,"id":"1","progress":0.4444444444444444,"total":123,"current":67}
|
||||
{"_e":"metro:bundling:progress","_t":1784293285950,"id":"1","progress":0.4444444444444444,"total":133,"current":83}
|
||||
{"_e":"metro:bundling:progress","_t":1784293286050,"id":"1","progress":0.4444444444444444,"total":155,"current":92}
|
||||
{"_e":"metro:bundling:progress","_t":1784293286155,"id":"1","progress":0.4444444444444444,"total":186,"current":105}
|
||||
{"_e":"metro:bundling:progress","_t":1784293286255,"id":"1","progress":0.4444444444444444,"total":186,"current":110}
|
||||
{"_e":"metro:bundling:progress","_t":1784293286355,"id":"1","progress":0.4444444444444444,"total":193,"current":119}
|
||||
{"_e":"metro:bundling:progress","_t":1784293286471,"id":"1","progress":0.4444444444444444,"total":200,"current":129}
|
||||
{"_e":"metro:bundling:progress","_t":1784293286534,"id":"1","progress":0.4444444444444444,"total":200,"current":130}
|
||||
{"_e":"metro:bundling:progress","_t":1784293286653,"id":"1","progress":0.4444444444444444,"total":232,"current":142}
|
||||
{"_e":"metro:bundling:progress","_t":1784293286753,"id":"1","progress":0.4444444444444444,"total":328,"current":149}
|
||||
{"_e":"metro:bundling:progress","_t":1784293286928,"id":"1","progress":0.4444444444444444,"total":328,"current":150}
|
||||
{"_e":"metro:bundling:progress","_t":1784293287051,"id":"1","progress":0.4444444444444444,"total":2077,"current":151}
|
||||
{"_e":"metro:bundling:progress","_t":1784293287151,"id":"1","progress":0.4444444444444444,"total":2091,"current":183}
|
||||
{"_e":"metro:bundling:progress","_t":1784293287251,"id":"1","progress":0.4444444444444444,"total":2105,"current":198}
|
||||
{"_e":"metro:bundling:progress","_t":1784293287351,"id":"1","progress":0.4444444444444444,"total":2172,"current":215}
|
||||
{"_e":"metro:bundling:progress","_t":1784293287465,"id":"1","progress":0.4444444444444444,"total":2225,"current":237}
|
||||
{"_e":"metro:bundling:progress","_t":1784293287568,"id":"1","progress":0.4444444444444444,"total":2248,"current":253}
|
||||
{"_e":"metro:bundling:progress","_t":1784293287668,"id":"1","progress":0.4444444444444444,"total":2271,"current":275}
|
||||
{"_e":"metro:bundling:progress","_t":1784293287768,"id":"1","progress":0.4444444444444444,"total":2309,"current":306}
|
||||
{"_e":"metro:bundling:progress","_t":1784293287868,"id":"1","progress":0.4444444444444444,"total":2322,"current":326}
|
||||
{"_e":"metro:bundling:progress","_t":1784293287990,"id":"1","progress":0.4444444444444444,"total":2322,"current":327}
|
||||
{"_e":"metro:bundling:progress","_t":1784293288090,"id":"1","progress":0.4444444444444444,"total":2326,"current":368}
|
||||
{"_e":"metro:bundling:progress","_t":1784293288198,"id":"1","progress":0.4444444444444444,"total":2326,"current":410}
|
||||
{"_e":"metro:bundling:progress","_t":1784293288305,"id":"1","progress":0.4444444444444444,"total":2326,"current":438}
|
||||
{"_e":"metro:bundling:progress","_t":1784293288405,"id":"1","progress":0.4444444444444444,"total":2326,"current":466}
|
||||
{"_e":"metro:bundling:progress","_t":1784293288506,"id":"1","progress":0.4444444444444444,"total":2326,"current":531}
|
||||
{"_e":"metro:bundling:progress","_t":1784293288606,"id":"1","progress":0.4444444444444444,"total":2326,"current":588}
|
||||
{"_e":"metro:bundling:progress","_t":1784293288707,"id":"1","progress":0.4444444444444444,"total":2326,"current":647}
|
||||
{"_e":"metro:bundling:progress","_t":1784293288807,"id":"1","progress":0.4444444444444444,"total":2326,"current":713}
|
||||
{"_e":"metro:bundling:progress","_t":1784293288908,"id":"1","progress":0.4444444444444444,"total":2326,"current":780}
|
||||
{"_e":"metro:bundling:progress","_t":1784293289008,"id":"1","progress":0.4444444444444444,"total":2326,"current":850}
|
||||
{"_e":"metro:bundling:progress","_t":1784293289108,"id":"1","progress":0.4444444444444444,"total":2326,"current":907}
|
||||
{"_e":"metro:bundling:progress","_t":1784293289211,"id":"1","progress":0.4444444444444444,"total":2326,"current":969}
|
||||
{"_e":"metro:bundling:progress","_t":1784293289311,"id":"1","progress":0.4444444444444444,"total":2326,"current":1040}
|
||||
{"_e":"metro:bundling:progress","_t":1784293289411,"id":"1","progress":0.4444444444444444,"total":2326,"current":1108}
|
||||
{"_e":"metro:bundling:progress","_t":1784293289511,"id":"1","progress":0.4444444444444444,"total":2326,"current":1179}
|
||||
{"_e":"metro:bundling:progress","_t":1784293289611,"id":"1","progress":0.4444444444444444,"total":2326,"current":1239}
|
||||
{"_e":"metro:bundling:progress","_t":1784293289711,"id":"1","progress":0.4444444444444444,"total":2326,"current":1307}
|
||||
{"_e":"metro:bundling:progress","_t":1784293289811,"id":"1","progress":0.4444444444444444,"total":2326,"current":1375}
|
||||
{"_e":"metro:bundling:progress","_t":1784293289911,"id":"1","progress":0.4444444444444444,"total":2326,"current":1447}
|
||||
{"_e":"metro:bundling:progress","_t":1784293290011,"id":"1","progress":0.4444444444444444,"total":2326,"current":1508}
|
||||
{"_e":"metro:bundling:progress","_t":1784293290111,"id":"1","progress":0.4614182344856344,"total":2326,"current":1580}
|
||||
{"_e":"metro:bundling:progress","_t":1784293290211,"id":"1","progress":0.49289333852838557,"total":2326,"current":1633}
|
||||
{"_e":"metro:bundling:progress","_t":1784293290311,"id":"1","progress":0.5347973005443715,"total":2326,"current":1701}
|
||||
{"_e":"metro:bundling:progress","_t":1784293290412,"id":"1","progress":0.5738420738609268,"total":2326,"current":1762}
|
||||
{"_e":"metro:bundling:progress","_t":1784293290512,"id":"1","progress":0.6115697239845065,"total":2326,"current":1819}
|
||||
{"_e":"metro:bundling:progress","_t":1784293290613,"id":"1","progress":0.6665465865327388,"total":2326,"current":1899}
|
||||
{"_e":"metro:bundling:progress","_t":1784293290713,"id":"1","progress":0.7100561967633445,"total":2326,"current":1960}
|
||||
{"_e":"metro:bundling:progress","_t":1784293290813,"id":"1","progress":0.7594305724883536,"total":2326,"current":2027}
|
||||
{"_e":"metro:bundling:progress","_t":1784293290913,"id":"1","progress":0.7974657485960396,"total":2346,"current":2095}
|
||||
{"_e":"metro:bundling:progress","_t":1784293291013,"id":"1","progress":0.7974657485960396,"total":2415,"current":2124}
|
||||
{"_e":"metro:bundling:progress","_t":1784293291115,"id":"1","progress":0.7974657485960396,"total":2472,"current":2162}
|
||||
{"_e":"metro:bundling:progress","_t":1784293291215,"id":"1","progress":0.7974657485960396,"total":2474,"current":2174}
|
||||
{"_e":"metro:bundling:progress","_t":1784293291315,"id":"1","progress":0.7974657485960396,"total":2519,"current":2215}
|
||||
{"_e":"metro:bundling:progress","_t":1784293291416,"id":"1","progress":0.7974657485960396,"total":2536,"current":2243}
|
||||
{"_e":"metro:bundling:progress","_t":1784293291519,"id":"1","progress":0.7974657485960396,"total":2569,"current":2280}
|
||||
{"_e":"metro:bundling:progress","_t":1784293291628,"id":"1","progress":0.7974657485960396,"total":2606,"current":2318}
|
||||
{"_e":"metro:bundling:progress","_t":1784293291728,"id":"1","progress":0.7974657485960396,"total":2659,"current":2329}
|
||||
{"_e":"metro:bundling:progress","_t":1784293291831,"id":"1","progress":0.7974657485960396,"total":2675,"current":2348}
|
||||
{"_e":"metro:bundling:progress","_t":1784293291931,"id":"1","progress":0.7974657485960396,"total":2687,"current":2378}
|
||||
{"_e":"metro:bundling:progress","_t":1784293292033,"id":"1","progress":0.7974657485960396,"total":2707,"current":2408}
|
||||
{"_e":"metro:bundling:progress","_t":1784293292133,"id":"1","progress":0.8159888890401819,"total":2710,"current":2448}
|
||||
{"_e":"metro:bundling:progress","_t":1784293292234,"id":"1","progress":0.8444485836968604,"total":2714,"current":2494}
|
||||
{"_e":"metro:bundling:progress","_t":1784293292333,"id":"1","progress":0.864934254482803,"total":2715,"current":2525}
|
||||
{"_e":"metro:bundling:progress","_t":1784293292433,"id":"1","progress":0.8762830037348633,"total":2723,"current":2549}
|
||||
{"_e":"metro:bundling:progress","_t":1784293292536,"id":"1","progress":0.8864205287942541,"total":2735,"current":2575}
|
||||
{"_e":"metro:bundling:progress","_t":1784293292636,"id":"1","progress":0.8979424496100379,"total":2748,"current":2604}
|
||||
{"_e":"metro:bundling:progress","_t":1784293292736,"id":"1","progress":0.9052557235874816,"total":2760,"current":2626}
|
||||
{"_e":"metro:bundling:progress","_t":1784293292836,"id":"1","progress":0.9052557235874816,"total":2779,"current":2637}
|
||||
{"_e":"metro:bundling:progress","_t":1784293292936,"id":"1","progress":0.9052557235874816,"total":2808,"current":2645}
|
||||
{"_e":"metro:bundling:progress","_t":1784293293039,"id":"1","progress":0.9052557235874816,"total":2817,"current":2655}
|
||||
{"_e":"metro:bundling:progress","_t":1784293293138,"id":"1","progress":0.9052557235874816,"total":2833,"current":2669}
|
||||
{"_e":"metro:bundling:progress","_t":1784293293239,"id":"1","progress":0.9052557235874816,"total":2843,"current":2686}
|
||||
{"_e":"metro:bundling:progress","_t":1784293293339,"id":"1","progress":0.9052557235874816,"total":2851,"current":2705}
|
||||
{"_e":"metro:bundling:progress","_t":1784293293439,"id":"1","progress":0.9066178148700104,"total":2864,"current":2727}
|
||||
{"_e":"metro:bundling:progress","_t":1784293293541,"id":"1","progress":0.9148469566887284,"total":2872,"current":2747}
|
||||
{"_e":"metro:bundling:progress","_t":1784293293641,"id":"1","progress":0.9148469566887284,"total":2901,"current":2764}
|
||||
{"_e":"metro:bundling:progress","_t":1784293293742,"id":"1","progress":0.9148469566887284,"total":2901,"current":2767}
|
||||
{"_e":"metro:bundling:progress","_t":1784293293842,"id":"1","progress":0.9148469566887284,"total":2913,"current":2773}
|
||||
{"_e":"metro:bundling:progress","_t":1784293293942,"id":"1","progress":0.9148469566887284,"total":2914,"current":2775}
|
||||
{"_e":"metro:bundling:progress","_t":1784293294081,"id":"1","progress":0.9148469566887284,"total":2915,"current":2779}
|
||||
{"_e":"metro:bundling:progress","_t":1784293294182,"id":"1","progress":0.9148469566887284,"total":2928,"current":2791}
|
||||
{"_e":"metro:bundling:progress","_t":1784293294282,"id":"1","progress":0.9148469566887284,"total":2949,"current":2805}
|
||||
{"_e":"metro:bundling:progress","_t":1784293294382,"id":"1","progress":0.9148469566887284,"total":2955,"current":2815}
|
||||
{"_e":"metro:bundling:progress","_t":1784293294482,"id":"1","progress":0.9148469566887284,"total":2998,"current":2824}
|
||||
{"_e":"metro:bundling:progress","_t":1784293294582,"id":"1","progress":0.9148469566887284,"total":3048,"current":2844}
|
||||
{"_e":"metro:bundling:progress","_t":1784293294682,"id":"1","progress":0.9148469566887284,"total":3050,"current":2853}
|
||||
{"_e":"metro:bundling:progress","_t":1784293294783,"id":"1","progress":0.9148469566887284,"total":3071,"current":2877}
|
||||
{"_e":"metro:bundling:progress","_t":1784293294884,"id":"1","progress":0.9148469566887284,"total":3091,"current":2891}
|
||||
{"_e":"metro:bundling:progress","_t":1784293294988,"id":"1","progress":0.9148469566887284,"total":3097,"current":2900}
|
||||
{"_e":"metro:bundling:progress","_t":1784293295088,"id":"1","progress":0.9148469566887284,"total":3108,"current":2914}
|
||||
{"_e":"metro:bundling:progress","_t":1784293295188,"id":"1","progress":0.9148469566887284,"total":3121,"current":2926}
|
||||
{"_e":"metro:bundling:progress","_t":1784293295288,"id":"1","progress":0.9148469566887284,"total":3123,"current":2935}
|
||||
{"_e":"metro:bundling:progress","_t":1784293295389,"id":"1","progress":0.9148469566887284,"total":3123,"current":2937}
|
||||
{"_e":"metro:bundling:progress","_t":1784293295489,"id":"1","progress":0.9148469566887284,"total":3123,"current":2943}
|
||||
{"_e":"metro:bundling:progress","_t":1784293295589,"id":"1","progress":0.9148469566887284,"total":3124,"current":2952}
|
||||
{"_e":"metro:bundling:progress","_t":1784293295689,"id":"1","progress":0.9148469566887284,"total":3125,"current":2974}
|
||||
{"_e":"metro:bundling:progress","_t":1784293295789,"id":"1","progress":0.9148469566887284,"total":3125,"current":2987}
|
||||
{"_e":"metro:bundling:progress","_t":1784293295892,"id":"1","progress":0.9198330727820985,"total":3128,"current":3000}
|
||||
{"_e":"metro:bundling:progress","_t":1784293295992,"id":"1","progress":0.9299565847185871,"total":3141,"current":3029}
|
||||
{"_e":"metro:bundling:progress","_t":1784293296094,"id":"1","progress":0.9492962002534049,"total":3154,"current":3073}
|
||||
{"_e":"metro:bundling:progress","_t":1784293296194,"id":"1","progress":0.9629720079436931,"total":3157,"current":3098}
|
||||
{"_e":"metro:bundling:progress","_t":1784293296294,"id":"1","progress":0.9629720079436931,"total":3173,"current":3108}
|
||||
{"_e":"metro:bundling:progress","_t":1784293296394,"id":"1","progress":0.9629720079436931,"total":3185,"current":3124}
|
||||
{"_e":"metro:bundling:progress","_t":1784293296494,"id":"1","progress":0.9648012926893179,"total":3210,"current":3153}
|
||||
{"_e":"metro:bundling:progress","_t":1784293296594,"id":"1","progress":0.9747663656275425,"total":3229,"current":3188}
|
||||
{"_e":"metro:bundling:progress","_t":1784293296694,"id":"1","progress":0.9834384092814489,"total":3247,"current":3220}
|
||||
{"_e":"metro:bundling:progress","_t":1784293296794,"id":"1","progress":0.9834384092814489,"total":3286,"current":3254}
|
||||
{"_e":"metro:bundling:progress","_t":1784293296903,"id":"1","progress":0.999,"total":3300,"current":3300}
|
||||
{"_e":"metro:bundling:done","_t":1784293297284,"id":"1","total":3300,"ms":15006.607121}
|
||||
{"_e":"metro:client_log","_t":1784293311557,"level":"error","data":["[Error: [Reanimated] Failed to find host instance for a ref.]","\n\n\u001b[1mCall Stack\u001b[22m\n\u001b[2m\u001b[90m SkiaPictureView (\u001b]8;;<anonymous>\u0007<anonymous>\u001b]8;;\u0007)\u001b[39m\u001b[22m\n\u001b[2m\u001b[90m Canvas (\u001b]8;;http:/192.168.1.25:8081/index.ts.bundle\u0007http:/192.168.1.25:8081/index.ts.bundle\u001b]8;;\u0007)\u001b[39m\u001b[22m"]}
|
||||
{"_e":"metro:bundling:started","_t":1784294061559,"id":"9","platform":"android","environment":null,"entry":"index.ts"}
|
||||
{"_e":"metro:bundling:progress","_t":1784294061559,"id":"9","progress":0,"total":1,"current":0}
|
||||
{"_e":"metro:bundling:done","_t":1784294061634,"id":"9","total":1,"ms":75.165257}
|
||||
{"_e":"metro:bundling:started","_t":1784295273557,"id":"b","platform":"android","environment":null,"entry":"index.ts"}
|
||||
{"_e":"metro:bundling:progress","_t":1784295273557,"id":"b","progress":0,"total":1,"current":0}
|
||||
{"_e":"metro:bundling:progress","_t":1784295273562,"id":"b","progress":0,"total":1,"current":0}
|
||||
{"_e":"metro:bundling:done","_t":1784295273724,"id":"b","total":1,"ms":167.330607}
|
||||
{"_e":"metro:bundling:started","_t":1784296589113,"id":"d","platform":"android","environment":null,"entry":"index.ts"}
|
||||
{"_e":"metro:bundling:progress","_t":1784296589113,"id":"d","progress":0,"total":1,"current":0}
|
||||
{"_e":"metro:bundling:progress","_t":1784296589124,"id":"d","progress":0,"total":1,"current":0}
|
||||
{"_e":"metro:bundling:progress","_t":1784296589255,"id":"d","progress":0,"total":1,"current":0}
|
||||
{"_e":"metro:bundling:progress","_t":1784296589338,"id":"d","progress":0,"total":1,"current":0}
|
||||
{"_e":"metro:bundling:progress","_t":1784296589438,"id":"d","progress":0,"total":1,"current":0}
|
||||
{"_e":"metro:bundling:progress","_t":1784296589538,"id":"d","progress":0,"total":1,"current":0}
|
||||
{"_e":"metro:bundling:progress","_t":1784296589638,"id":"d","progress":0,"total":1,"current":0}
|
||||
{"_e":"metro:bundling:progress","_t":1784296589738,"id":"d","progress":0,"total":1,"current":0}
|
||||
{"_e":"metro:bundling:progress","_t":1784296589852,"id":"d","progress":0,"total":1,"current":0}
|
||||
{"_e":"metro:bundling:progress","_t":1784296589952,"id":"d","progress":0,"total":1,"current":0}
|
||||
{"_e":"metro:bundling:progress","_t":1784296590056,"id":"d","progress":0,"total":1,"current":0}
|
||||
{"_e":"metro:bundling:progress","_t":1784296590156,"id":"d","progress":0,"total":1,"current":0}
|
||||
{"_e":"metro:bundling:progress","_t":1784296590256,"id":"d","progress":0,"total":1,"current":0}
|
||||
{"_e":"metro:bundling:progress","_t":1784296590304,"id":"d","progress":0,"total":1,"current":0}
|
||||
{"_e":"metro:bundling:done","_t":1784296590410,"id":"d","total":1,"ms":1297.260922}
|
||||
{"_e":"metro:bundling:started","_t":1784296639397,"id":"e","platform":"android","environment":null,"entry":"index.ts"}
|
||||
{"_e":"metro:bundling:progress","_t":1784296639397,"id":"e","progress":0,"total":1,"current":0}
|
||||
{"_e":"metro:bundling:done","_t":1784296639495,"id":"e","total":1,"ms":97.436217}
|
||||
{"_e":"metro:bundling:started","_t":1784299195451,"id":"f","platform":"android","environment":null,"entry":"index.ts"}
|
||||
{"_e":"metro:bundling:progress","_t":1784299195476,"id":"f","progress":0,"total":1,"current":0}
|
||||
{"_e":"metro:bundling:progress","_t":1784299195479,"id":"f","progress":0,"total":1,"current":0}
|
||||
{"_e":"metro:bundling:done","_t":1784299196005,"id":"f","total":1,"ms":529.887212}
|
||||
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"devices": [
|
||||
{
|
||||
"installationId": "3ac39ffb-519e-4fcd-b686-c20d0a63c449",
|
||||
"lastUsed": 1784299195276
|
||||
}
|
||||
]
|
||||
}
|
||||
|
Before Width: | Height: | Size: 3.3 KiB |
|
Before Width: | Height: | Size: 5.8 KiB |
|
Before Width: | Height: | Size: 8.6 KiB |
|
Before Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 5.5 KiB |
|
Before Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 39 KiB |
|
Before Width: | Height: | Size: 66 KiB |
|
Before Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 2.1 KiB |
|
Before Width: | Height: | Size: 2.6 KiB |
|
Before Width: | Height: | Size: 4.9 KiB |
|
Before Width: | Height: | Size: 5.2 KiB |
|
Before Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 3.3 KiB |
|
Before Width: | Height: | Size: 6.4 KiB |
|
Before Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 29 KiB |
|
Before Width: | Height: | Size: 3.0 KiB |
|
Before Width: | Height: | Size: 5.7 KiB |
|
Before Width: | Height: | Size: 9.0 KiB |
|
Before Width: | Height: | Size: 1.4 KiB |
@@ -1 +1,2 @@
|
||||
node_modules
|
||||
.expo/
|
||||
|
||||
@@ -13,23 +13,22 @@ import Viewfinder, { type ViewfinderHandle } from './src/components/Viewfinder';
|
||||
import AdjustmentPanel from './src/components/AdjustmentPanel';
|
||||
import CameraControls from './src/components/CameraControls';
|
||||
import ToolRail, { TabId } from './src/components/ToolRail';
|
||||
import PreviewModal from './src/components/PreviewModal';
|
||||
import SettingsModal from './src/components/SettingsModal';
|
||||
import PhotoViewerModal from './src/components/PhotoViewerModal';
|
||||
|
||||
import { Recipe, ColorAdjustments, FrameId, GPSInfo, AspectRatio } from './src/types';
|
||||
import { Recipe, ColorAdjustments, FrameId, GPSInfo, AspectRatio, MeterMode, PhotoEntry, ShutterSound } from './src/types';
|
||||
import { getAllRecipes, saveCustomRecipe, deleteCustomRecipe } from './src/utils/storageUtils';
|
||||
import { DEFAULT_ADJUSTMENTS } from './src/utils/defaultRecipes';
|
||||
import { getCurrentGPS, requestLocationPermissions } from './src/utils/locationUtils';
|
||||
import { resolvePhotoGPS } from './src/utils/photoGps';
|
||||
import { processAndExportPhoto } from './src/utils/exportEngine';
|
||||
import { PARAM_DEFS } from './src/utils/paramDefs';
|
||||
|
||||
// @ts-ignore
|
||||
import './global.css';
|
||||
|
||||
const SESSION_KEY = 'saved_session';
|
||||
|
||||
// DEBUG-4x3 (remove after diagnosis): build marker — prints once at JS eval.
|
||||
console.error('[camdebug-ver] debug build console.error path');
|
||||
|
||||
export default function App() {
|
||||
const [mode, setMode] = useState<'camera' | 'library'>('camera');
|
||||
// Imperative handle into the Viewfinder camera controller: flushes the EV to
|
||||
@@ -48,14 +47,53 @@ export default function App() {
|
||||
// Composition aspect-ratio (FULL / 4:3 / 3:2 upright) + RAW DNG sidecar.
|
||||
const [aspectRatio, setAspectRatio] = useState<AspectRatio>('full');
|
||||
const [rawEnabled, setRawEnabled] = useState(false);
|
||||
// Full-frame metering mode (FOCUS → METERING MODE): highlight-weighted meters
|
||||
// the BRIGHTEST area to protect its detail (never blow whites) and pushes the
|
||||
// shadows very deep — extreme contrast (GR/Sony). Realised as: a small EV dip
|
||||
// (capture headroom; NOT a full-frame dim), a highlight roll (keep bright
|
||||
// detail), deep shadow crush and raised contrast/clarity. All constants are a
|
||||
// starting approximation — tune after device testing.
|
||||
const [meterMode, setMeterMode] = useState<MeterMode>('evaluative');
|
||||
const METER_HL_EV = -0.5;
|
||||
const METER_HL_HIGHLIGHT = -3;
|
||||
const METER_HL_SHADOW = -4;
|
||||
const METER_HL_CONTRAST = 3;
|
||||
const METER_HL_CLARITY = 3;
|
||||
const clampAdj = (v: number) => Math.max(-10, Math.min(10, v));
|
||||
const meteringActive = mode === 'camera' && meterMode === 'highlight';
|
||||
const meteringAdj: Partial<ColorAdjustments> | undefined = meteringActive
|
||||
? {
|
||||
highlight: clampAdj((adjustments.highlight ?? 0) + METER_HL_HIGHLIGHT),
|
||||
shadow: clampAdj((adjustments.shadow ?? 0) + METER_HL_SHADOW),
|
||||
contrast: clampAdj((adjustments.contrast ?? 0) + METER_HL_CONTRAST),
|
||||
clarity: clampAdj((adjustments.clarity ?? 0) + METER_HL_CLARITY),
|
||||
}
|
||||
: undefined;
|
||||
// Same tonal overlay for the camera EXPORT (the EV dip is not inside — the
|
||||
// hardware AE bias at capture already carries it, evFromCamera skips software
|
||||
// EV, so folding the dip here would darken twice).
|
||||
const renderAdj: ColorAdjustments = meteringAdj ? { ...adjustments, ...meteringAdj } : adjustments;
|
||||
const rawFailRef = useRef(false);
|
||||
// Progressive disclosure: a tool chip opens its panel; null = photo hero only.
|
||||
const [openTab, setOpenTab] = useState<TabId | null>(null);
|
||||
// The parameter whose slider row is open inside the panel — lifted here (not
|
||||
// local to AdjustmentPanel) because an open row arms drag-anywhere-on-the-
|
||||
// image adjustment in the Viewfinder. Cleared when the panel/tab closes.
|
||||
const [openParamKey, setOpenParamKey] = useState<string | null>(null);
|
||||
|
||||
const [libraryImageUri, setLibraryImageUri] = useState<string | null>(null);
|
||||
const [lastPhotoUri, setLastPhotoUri] = useState<string | null>(null);
|
||||
const [lastSavedToLibrary, setLastSavedToLibrary] = useState(false);
|
||||
const [previewVisible, setPreviewVisible] = useState(false);
|
||||
// Session photo history (camera captures + library exports). The camera
|
||||
// thumbnail/preview button shows the newest entry; the swipe viewer walks the
|
||||
// whole list. Not persisted across restarts — entries are app-cache files.
|
||||
const [photoHistory, setPhotoHistory] = useState<PhotoEntry[]>([]);
|
||||
// Index into photoHistory of the photo shown in the swipe viewer; null = closed.
|
||||
const [viewerIndex, setViewerIndex] = useState<number | null>(null);
|
||||
// Gear sheet (sound / ratio / metering mode). Camera captures never auto-open
|
||||
// the viewer anymore — the thumbnail button does.
|
||||
const [settingsVisible, setSettingsVisible] = useState(false);
|
||||
// Shutter click: only OFF/ON (no volume API on Android). Applied at capture.
|
||||
const [shutterSound, setShutterSound] = useState<ShutterSound>('on');
|
||||
const photoSeq = useRef(0);
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
|
||||
// Hardware Permissions (vision-camera)
|
||||
@@ -96,6 +134,8 @@ export default function App() {
|
||||
selectedFrame,
|
||||
useGeotag,
|
||||
aspectRatio,
|
||||
meterMode,
|
||||
shutterSound,
|
||||
selectedRecipeId: selectedRecipe?.id || null,
|
||||
gpsInfo,
|
||||
};
|
||||
@@ -107,7 +147,7 @@ export default function App() {
|
||||
}
|
||||
});
|
||||
return () => subscription.remove();
|
||||
}, [mode, libraryImageUri, adjustments, selectedFrame, useGeotag, selectedRecipe, gpsInfo, aspectRatio]);
|
||||
}, [mode, libraryImageUri, adjustments, selectedFrame, useGeotag, selectedRecipe, gpsInfo, aspectRatio, meterMode, shutterSound]);
|
||||
|
||||
const initSession = async () => {
|
||||
const list = await getAllRecipes();
|
||||
@@ -144,6 +184,8 @@ export default function App() {
|
||||
if (session.selectedFrame) setSelectedFrame(session.selectedFrame);
|
||||
if (session.useGeotag !== undefined) setUseGeotag(session.useGeotag);
|
||||
if (session.aspectRatio === '4:3' || session.aspectRatio === '3:2') setAspectRatio(session.aspectRatio);
|
||||
if (session.meterMode === 'evaluative' || session.meterMode === 'highlight') setMeterMode(session.meterMode);
|
||||
if (session.shutterSound === 'on' || session.shutterSound === 'off') setShutterSound(session.shutterSound);
|
||||
if (session.gpsInfo) setGpsInfo(session.gpsInfo);
|
||||
if (session.selectedRecipeId) {
|
||||
restoredRecipe = list.find((r: Recipe) => r.id === session.selectedRecipeId) || null;
|
||||
@@ -172,6 +214,13 @@ export default function App() {
|
||||
};
|
||||
|
||||
const handleUpdateAdjustments = (updates: Partial<ColorAdjustments>) => {
|
||||
// Storm guard, generic across every param: same-value writes (slider
|
||||
// micro-jitter / repeated pointer-move events) still build a fresh
|
||||
// adjustments object and re-render the whole tree — skip them. Supersedes
|
||||
// the old EV-only dedupe; EV flush at capture reads its own ref, so a
|
||||
// skipped identical EV needs no re-send.
|
||||
const keys = Object.keys(updates) as (keyof ColorAdjustments)[];
|
||||
if (keys.every((k) => updates[k] === adjustments[k])) return;
|
||||
// B8: never call a setState inside another setState's updater.
|
||||
const next = { ...adjustments, ...updates };
|
||||
setAdjustments(next);
|
||||
@@ -214,9 +263,6 @@ export default function App() {
|
||||
|
||||
const handleAspectRatio = (ratio: AspectRatio) => {
|
||||
setAspectRatio(ratio);
|
||||
if (openTab === 'ratio') {
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRawEnabled = (enabled: boolean) => {
|
||||
@@ -236,7 +282,7 @@ export default function App() {
|
||||
// in that state, drop RAW (removing the output reconfigures back to the
|
||||
// single working JPEG session) instead of leaving a dead camera.
|
||||
const handleCameraSessionError = useCallback((error: Error) => {
|
||||
console.error('[camera-session] error:', error);
|
||||
console.error('Camera session error:', error);
|
||||
if (rawEnabledRef.current && !rawFailRef.current) {
|
||||
rawFailRef.current = true;
|
||||
setRawEnabled(false);
|
||||
@@ -264,7 +310,6 @@ export default function App() {
|
||||
} else {
|
||||
await MediaLibraryLegacy.createAlbumAsync(albumName, asset);
|
||||
}
|
||||
console.error(`[raw] saved DNG to album ${albumName}`);
|
||||
return true;
|
||||
} catch (e) {
|
||||
console.error('RAW album save failed:', e);
|
||||
@@ -287,6 +332,16 @@ export default function App() {
|
||||
Alert.alert('Recipe Saved', `"${name}" has been created.`);
|
||||
};
|
||||
|
||||
// PRESETS → CREATE quick-input: build a brand-new recipe from the modal's
|
||||
// typed fields, persist it, and apply it (the user sees the look immediately).
|
||||
const handleCreateRecipe = async (recipe: Omit<Recipe, 'id' | 'isCustom'>) => {
|
||||
const newRecipe = await saveCustomRecipe(recipe);
|
||||
const list = await getAllRecipes();
|
||||
setRecipes(list);
|
||||
applyRecipe(newRecipe);
|
||||
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
|
||||
};
|
||||
|
||||
const handleDeleteRecipe = async (id: string) => {
|
||||
await deleteCustomRecipe(id);
|
||||
const list = await getAllRecipes();
|
||||
@@ -309,10 +364,9 @@ export default function App() {
|
||||
try {
|
||||
const perm = await MediaLibrary.requestPermissionsAsync(false, ['photo']);
|
||||
mediaOk = perm.granted;
|
||||
console.error(`[gps] pre-pick media+location permission granted=${perm.granted}`);
|
||||
} catch (e) {
|
||||
mediaOk = false;
|
||||
console.error('[gps] pre-pick media permission error: ' + String(e).slice(0, 120));
|
||||
console.warn('Pre-pick media+photo-location permission error:', e);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -333,7 +387,6 @@ export default function App() {
|
||||
|
||||
if (!result.canceled && result.assets && result.assets.length > 0) {
|
||||
const asset = result.assets[0];
|
||||
console.error(`[gps] picked uri=${asset.uri} assetId=${asset.assetId || 'null'} exifGPS=${asset.exif ? JSON.stringify(asset.exif.GPSLatitude ?? null) : 'no-exif'}`);
|
||||
setLibraryImageUri(asset.uri);
|
||||
setMode('library');
|
||||
// Clear old GPS — will be replaced if the photo (or device) has location data
|
||||
@@ -351,9 +404,6 @@ export default function App() {
|
||||
if (permissionDenied) {
|
||||
console.warn('Media library read permission denied — photo GPS unavailable.');
|
||||
}
|
||||
console.error(
|
||||
`[gps] resolved ${gps ? `src=${gps.source} lat=${gps.latitude.toFixed(5)} lon=${gps.longitude.toFixed(5)} loc=${(gps.locality || '').slice(0, 40)}` : 'none'}`
|
||||
);
|
||||
|
||||
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
|
||||
}
|
||||
@@ -369,6 +419,9 @@ export default function App() {
|
||||
useGeotag,
|
||||
};
|
||||
|
||||
// Thumbnail/preview target for the camera-mode right button: newest photo.
|
||||
const lastPhotoUri = photoHistory.length > 0 ? photoHistory[photoHistory.length - 1].uri : null;
|
||||
|
||||
const handleCapture = async () => {
|
||||
if (isProcessing) return;
|
||||
|
||||
@@ -403,10 +456,9 @@ export default function App() {
|
||||
try {
|
||||
await viewfinderRef.current?.flushExposureBias();
|
||||
} catch (e) {
|
||||
console.error('[ev] flush at capture failed: ' + String(e));
|
||||
console.warn('EV flush at capture failed:', e);
|
||||
}
|
||||
const { filePath } = await photoOutput.capturePhotoToFile({}, {});
|
||||
console.error(`[capture] shot-took ${Date.now() - tShot}ms`);
|
||||
const { filePath } = await photoOutput.capturePhotoToFile({ enableShutterSound: shutterSound === 'on' }, {});
|
||||
const sourceUri = toUri(filePath);
|
||||
|
||||
// RAW DNG sidecar: fire the second exposure immediately after the JPEG
|
||||
@@ -414,9 +466,7 @@ export default function App() {
|
||||
// "RAW" album without blocking the JPEG export below.
|
||||
if (rawEnabled && rawPhotoOutput) {
|
||||
try {
|
||||
const tRaw = Date.now();
|
||||
const rawFile = await rawPhotoOutput.capturePhotoToFile({}, {});
|
||||
console.error(`[capture] raw-shot-took ${Date.now() - tRaw}ms`);
|
||||
const rawFile = await rawPhotoOutput.capturePhotoToFile({ enableShutterSound: shutterSound === 'on' }, {});
|
||||
const rawUri = toUri(rawFile.filePath);
|
||||
saveRawToAlbum(rawUri).then((ok) => {
|
||||
if (!ok) {
|
||||
@@ -431,7 +481,7 @@ export default function App() {
|
||||
const tExp = Date.now();
|
||||
const result = await processAndExportPhoto(
|
||||
sourceUri,
|
||||
{ ...activeRecipe, adjustments, frameId: selectedFrame, useGeotag },
|
||||
{ ...activeRecipe, adjustments: renderAdj, frameId: selectedFrame, useGeotag },
|
||||
selectedFrame,
|
||||
useGeotag,
|
||||
captureGps,
|
||||
@@ -445,12 +495,16 @@ export default function App() {
|
||||
aspect: aspectRatio === 'full' ? undefined : aspectRatio,
|
||||
}
|
||||
);
|
||||
console.error(`[capture] export-took ${Date.now() - tExp}ms (pipeline total ${Date.now() - tShot}ms)`);
|
||||
|
||||
if (result) {
|
||||
setLastPhotoUri(result.uri);
|
||||
setLastSavedToLibrary(result.savedToLibrary);
|
||||
setPreviewVisible(true);
|
||||
// No auto preview after a capture — the thumbnail button opens the
|
||||
// swipe viewer. Saved to session history either way.
|
||||
pushPhoto({
|
||||
uri: result.uri,
|
||||
recipeName: activeRecipe.name,
|
||||
savedToLibrary: result.savedToLibrary,
|
||||
assetId: result.assetId,
|
||||
});
|
||||
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
|
||||
} else {
|
||||
Alert.alert('Failed', 'Error occurred while processing the captured photo.');
|
||||
@@ -491,9 +545,15 @@ export default function App() {
|
||||
setIsProcessing(false);
|
||||
|
||||
if (result) {
|
||||
setLastPhotoUri(result.uri);
|
||||
setLastSavedToLibrary(result.savedToLibrary);
|
||||
setPreviewVisible(true);
|
||||
pushPhoto({
|
||||
uri: result.uri,
|
||||
recipeName: activeRecipe.name,
|
||||
savedToLibrary: result.savedToLibrary,
|
||||
assetId: result.assetId,
|
||||
});
|
||||
// Library has no thumbnail button (right button switches back to camera),
|
||||
// so the export result auto-opens the viewer on the newest photo.
|
||||
setViewerIndex(photoHistory.length);
|
||||
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
|
||||
} else {
|
||||
Alert.alert('Failed', 'Error occurred while processing image.');
|
||||
@@ -509,6 +569,115 @@ export default function App() {
|
||||
setMode(next);
|
||||
};
|
||||
|
||||
// Append a finished export (camera capture or library export) to the session
|
||||
// photo history. Returned entry id lets callers delete by id.
|
||||
const pushPhoto = (p: Omit<PhotoEntry, 'id'>): string => {
|
||||
const id = `photo-${Date.now()}-${photoSeq.current++}`;
|
||||
setPhotoHistory((h) => [...h, { id, ...p }]);
|
||||
return id;
|
||||
};
|
||||
|
||||
// Thumbnail (camera mode) opens the viewer on the newest photo.
|
||||
const openLatestViewer = () => {
|
||||
if (photoHistory.length > 0) setViewerIndex(photoHistory.length - 1);
|
||||
};
|
||||
|
||||
// Viewer trash: delete the photo from the app AND its gallery copy (when it
|
||||
// was saved). Deleting a saved photo goes through Android's per-item system
|
||||
// confirmation (scoped storage — every single delete asks again by design);
|
||||
// if the user cancels THAT dialog the photo is kept in both places.
|
||||
const handleDeletePhoto = async (id: string) => {
|
||||
const idx = photoHistory.findIndex((p) => p.id === id);
|
||||
if (idx < 0) return;
|
||||
const entry = photoHistory[idx];
|
||||
const remove = () => {
|
||||
const next = photoHistory.filter((p) => p.id !== id);
|
||||
setPhotoHistory(next);
|
||||
setViewerIndex((prev) => {
|
||||
if (prev === null) return null;
|
||||
if (next.length === 0) return null;
|
||||
if (prev > idx) return prev - 1;
|
||||
if (prev >= next.length) return next.length - 1;
|
||||
return prev;
|
||||
});
|
||||
};
|
||||
const deleteCache = async () => {
|
||||
try {
|
||||
await FileSystem.deleteAsync(entry.uri, { idempotent: true });
|
||||
} catch (e) {
|
||||
console.warn('Photo cache delete failed:', e);
|
||||
}
|
||||
};
|
||||
|
||||
if (!entry.savedToLibrary || !entry.assetId) {
|
||||
// Never reached the gallery — nothing for the OS to confirm.
|
||||
remove();
|
||||
deleteCache();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
// deleteAssetsAsync shows the OS delete confirmation when the photo is
|
||||
// in the gallery. false = user cancelled it → keep everything.
|
||||
const ok = await MediaLibraryLegacy.deleteAssetsAsync([entry.assetId]);
|
||||
if (!ok) {
|
||||
Alert.alert('Photo Kept', 'Android did not confirm the deletion, so the photo stays here and in your gallery.');
|
||||
return;
|
||||
}
|
||||
remove();
|
||||
deleteCache();
|
||||
} catch (e) {
|
||||
console.warn('Gallery delete failed:', e);
|
||||
Alert.alert('Delete Failed', 'Could not remove the photo from your gallery. It stays here too.');
|
||||
}
|
||||
};
|
||||
|
||||
// Viewer SAVE & DONE: photo was exported to cache only — copy it into the
|
||||
// gallery now and remember its assetId so a later trash can remove it.
|
||||
const handleSavePhoto = async (id: string) => {
|
||||
const entry = photoHistory.find((p) => p.id === id);
|
||||
if (!entry || entry.savedToLibrary) return;
|
||||
const perm = await MediaLibraryLegacy.requestPermissionsAsync();
|
||||
if (!perm.granted) {
|
||||
Alert.alert('Not Saved', 'Media library access was denied, so this photo was not saved to your gallery.');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const asset = await MediaLibraryLegacy.createAssetAsync(entry.uri);
|
||||
setPhotoHistory((h) => h.map((p) => (p.id === id ? { ...p, savedToLibrary: true, assetId: asset.id } : p)));
|
||||
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
|
||||
} catch (e) {
|
||||
console.error('Save to gallery failed:', e);
|
||||
Alert.alert('Save Failed', 'Could not save this photo to your gallery.');
|
||||
}
|
||||
};
|
||||
|
||||
// Tool-rail toggle: closing the panel (or switching tabs) also drops the open
|
||||
// parameter row — otherwise a stale slider row (and its image-drag target)
|
||||
// would survive into the next panel.
|
||||
const handleToggleTab = (tab: TabId) => {
|
||||
setOpenTab((prev) => (prev === tab ? null : tab));
|
||||
setOpenParamKey(null);
|
||||
};
|
||||
|
||||
// While a panel parameter row is open, a vertical drag anywhere on the live
|
||||
// camera image adjusts that same parameter (the open slider follows). Open
|
||||
// row wins over the locked-AE EV drag; a plain tap still locks focus.
|
||||
const imageAdjustTarget = (() => {
|
||||
if (mode !== 'camera' || openTab !== 'iq' && openTab !== 'wb' && openTab !== 'filters') return null;
|
||||
if (!openParamKey) return null;
|
||||
const list = openTab === 'iq' ? PARAM_DEFS.iq : openTab === 'wb' ? PARAM_DEFS.wb : PARAM_DEFS.filters;
|
||||
const def = list.find((d) => d.key === openParamKey);
|
||||
if (!def) return null;
|
||||
return {
|
||||
key: def.key,
|
||||
min: def.min,
|
||||
max: def.max,
|
||||
step: def.step,
|
||||
value: def.get(adjustments),
|
||||
onChange: (v: number) => handleUpdateAdjustments(def.set(v)),
|
||||
};
|
||||
})();
|
||||
|
||||
return (
|
||||
<View className="flex-1 bg-black">
|
||||
<StatusBar barStyle="light-content" translucent backgroundColor="transparent" />
|
||||
@@ -540,6 +709,9 @@ export default function App() {
|
||||
aspectRatio={aspectRatio}
|
||||
onSessionError={handleCameraSessionError}
|
||||
onExposureChange={(ev) => handleUpdateAdjustments({ exposureCompensation: ev })}
|
||||
meteringEvOffset={meteringActive ? METER_HL_EV : 0}
|
||||
meteringAdjustments={meteringAdj}
|
||||
imageAdjustTarget={imageAdjustTarget}
|
||||
/>
|
||||
</View>
|
||||
|
||||
@@ -564,11 +736,10 @@ export default function App() {
|
||||
adjustments={adjustments}
|
||||
selectedFrame={selectedFrame}
|
||||
useGeotag={useGeotag}
|
||||
aspectRatio={aspectRatio}
|
||||
rawEnabled={rawEnabled}
|
||||
cameraMode={mode === 'camera'}
|
||||
onAspectRatio={handleAspectRatio}
|
||||
onRawEnabled={handleRawEnabled}
|
||||
openParam={openParamKey}
|
||||
onOpenParamChange={setOpenParamKey}
|
||||
currentBaseFilter={selectedRecipe?.baseFilter}
|
||||
onCreateRecipe={handleCreateRecipe}
|
||||
onSelectRecipe={applyRecipe}
|
||||
onUpdateAdjustments={handleUpdateAdjustments}
|
||||
onUpdateFrame={handleUpdateFrame}
|
||||
@@ -579,25 +750,42 @@ export default function App() {
|
||||
</View>
|
||||
)}
|
||||
|
||||
<ToolRail activeTab={openTab} onToggle={(tab) => setOpenTab(openTab === tab ? null : tab)} />
|
||||
<ToolRail activeTab={openTab} onToggle={handleToggleTab} />
|
||||
|
||||
<CameraControls
|
||||
lastPhotoUri={lastPhotoUri}
|
||||
onCapture={handleCapture}
|
||||
onPickImage={handlePickImage}
|
||||
onOpenPreview={() => setPreviewVisible(true)}
|
||||
onOpenPreview={openLatestViewer}
|
||||
onOpenSettings={() => setSettingsVisible(true)}
|
||||
isLibraryMode={mode === 'library'}
|
||||
onSwitchToCamera={() => handleSetMode('camera')}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* Preview Modal */}
|
||||
<PreviewModal
|
||||
visible={previewVisible}
|
||||
onClose={() => setPreviewVisible(false)}
|
||||
photoUri={lastPhotoUri}
|
||||
recipeName={selectedRecipe?.name || 'Custom'}
|
||||
savedToLibrary={lastSavedToLibrary}
|
||||
{/* Gear sheet: shutter sound / ratio+RAW / metering mode */}
|
||||
<SettingsModal
|
||||
visible={settingsVisible}
|
||||
onClose={() => setSettingsVisible(false)}
|
||||
aspectRatio={aspectRatio}
|
||||
onAspectRatio={handleAspectRatio}
|
||||
rawEnabled={rawEnabled}
|
||||
onRawEnabled={handleRawEnabled}
|
||||
cameraMode={mode === 'camera'}
|
||||
meterMode={meterMode}
|
||||
onMeterModeChange={setMeterMode}
|
||||
shutterSound={shutterSound}
|
||||
onShutterSound={setShutterSound}
|
||||
/>
|
||||
|
||||
{/* Session photo viewer (swipe across captured/exported photos) */}
|
||||
<PhotoViewerModal
|
||||
visible={viewerIndex !== null && photoHistory.length > 0}
|
||||
photos={photoHistory}
|
||||
initialIndex={viewerIndex ?? 0}
|
||||
onClose={() => setViewerIndex(null)}
|
||||
onDelete={handleDeletePhoto}
|
||||
onSave={handleSavePhoto}
|
||||
/>
|
||||
|
||||
{/* Global Processing Loader Overlay */}
|
||||
|
||||
@@ -3,8 +3,11 @@ import { View, Text, TouchableOpacity, ScrollView } from 'react-native';
|
||||
import Slider from '@react-native-community/slider';
|
||||
import * as Haptics from 'expo-haptics';
|
||||
import { Trash2 } from 'lucide-react-native';
|
||||
import { Recipe, ColorAdjustments, FrameId, AspectRatio } from '../types';
|
||||
import { Recipe, ColorAdjustments, FrameId, BaseFilter } from '../types';
|
||||
import { FRAMES } from '../utils/frameUtils';
|
||||
import { PARAM_DEFS, ContinuousParamDef } from '../utils/paramDefs';
|
||||
import { FILM_SIMS, filmSimToRecipe } from '../utils/defaultRecipes';
|
||||
import RecipeCreateModal from './RecipeCreateModal';
|
||||
|
||||
// Compact bottom sheet above the tool rail:
|
||||
// bottom row = the tool's chips (presets / parameters), horizontally scrollable
|
||||
@@ -12,7 +15,7 @@ import { FRAMES } from '../utils/frameUtils';
|
||||
// Tapping a parameter chip toggles its slider row; chips stay visible.
|
||||
// Darkroom-flavoured: amber = active/edited, red = destructive, mono labels.
|
||||
|
||||
type TabId = 'recipes' | 'iq' | 'wb' | 'filters' | 'frame' | 'ratio';
|
||||
type TabId = 'recipes' | 'iq' | 'wb' | 'filters' | 'frame';
|
||||
// Choice-group keys that open a strip above the chip row: color chrome groups
|
||||
// (wb tab) and the D.RANGE selector (iq tab).
|
||||
type GroupKey = 'cx' | 'cxb' | 'dr';
|
||||
@@ -24,16 +27,23 @@ interface AdjustmentPanelProps {
|
||||
adjustments: ColorAdjustments;
|
||||
selectedFrame: FrameId;
|
||||
useGeotag: boolean;
|
||||
aspectRatio: AspectRatio;
|
||||
rawEnabled: boolean;
|
||||
// RAW (DNG sidecar) is only meaningful while the live camera is the source.
|
||||
cameraMode: boolean;
|
||||
// RAW (DNG sidecar) / aspect-ratio / metering-mode now live in the gear
|
||||
// Settings sheet (gear between library picker and shutter) — no longer
|
||||
// tool-rail tabs.
|
||||
// The parameter whose slider row is open — lifted to App so it can also drive
|
||||
// drag-anywhere-on-the-image adjustment while the row is open.
|
||||
openParam: string | null;
|
||||
onOpenParamChange: (key: string | null) => void;
|
||||
// Base filter of the currently applied recipe — seeds the CREATE modal's
|
||||
// SIMULATION row so quick-input starts from the active look.
|
||||
currentBaseFilter?: BaseFilter;
|
||||
// CREATE quick-input saves a brand-new recipe (name + adjustments from the
|
||||
// modal) and applies it, unlike onSaveRecipe which snapshots the live state.
|
||||
onCreateRecipe: (recipe: Omit<Recipe, 'id' | 'isCustom'>) => void;
|
||||
onSelectRecipe: (recipe: Recipe) => void;
|
||||
onUpdateAdjustments: (adjustments: Partial<ColorAdjustments>) => void;
|
||||
onUpdateFrame: (frameId: FrameId) => void;
|
||||
onToggleGeotag: (enabled: boolean) => void;
|
||||
onAspectRatio: (ratio: AspectRatio) => void;
|
||||
onRawEnabled: (enabled: boolean) => void;
|
||||
onSaveRecipe: (name: string) => void;
|
||||
onDeleteRecipe: (id: string) => void;
|
||||
}
|
||||
@@ -62,8 +72,19 @@ interface ParamDef {
|
||||
}
|
||||
|
||||
const haptic = () => Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||
const sign = (v: number) => (v > 0 ? `+${v}` : String(v));
|
||||
const oneEv = (v: number) => `${v > 0 ? '+' : ''}${v.toFixed(1)} EV`;
|
||||
|
||||
// WB preset → engine pair. AUTO and DAYLIGHT both land on the neutral
|
||||
// 5500K / 0 pair (the engine has no scene meter), so the pair alone cannot say
|
||||
// which chip is active — the last tapped preset is remembered to disambiguate.
|
||||
const WB_PAIRS: Record<string, { t: number; ti: number }> = {
|
||||
auto: { t: 5500, ti: 0 },
|
||||
daylight: { t: 5500, ti: 0 },
|
||||
'daylight-3r': { t: 5500, ti: -3 },
|
||||
cloudy: { t: 6500, ti: 1 },
|
||||
shade: { t: 8000, ti: 2 },
|
||||
tungsten: { t: 3200, ti: 0 },
|
||||
fluorescent: { t: 4000, ti: 3 },
|
||||
};
|
||||
|
||||
export default function AdjustmentPanel({
|
||||
activeTab,
|
||||
@@ -72,31 +93,36 @@ export default function AdjustmentPanel({
|
||||
adjustments,
|
||||
selectedFrame,
|
||||
useGeotag,
|
||||
aspectRatio,
|
||||
rawEnabled,
|
||||
cameraMode,
|
||||
openParam,
|
||||
onOpenParamChange,
|
||||
currentBaseFilter,
|
||||
onCreateRecipe,
|
||||
onSelectRecipe,
|
||||
onUpdateAdjustments,
|
||||
onUpdateFrame,
|
||||
onToggleGeotag,
|
||||
onAspectRatio,
|
||||
onRawEnabled,
|
||||
onDeleteRecipe,
|
||||
}: AdjustmentPanelProps) {
|
||||
// The single parameter whose slider row is open — a new selection replaces
|
||||
// the previous row (one row at a time, above the chips).
|
||||
const [openParam, setOpenParam] = useState<string | null>(null);
|
||||
// The single choice-group whose strip (OFF/WEAK/STRONG, or the DR ladder) is
|
||||
// open above the chips (same one-row-at-a-time pattern as openParam).
|
||||
// open above the chips (same one-row-at-a-time pattern as openParam — which
|
||||
// lives in App now, since an open row also arms image-drag adjustment).
|
||||
const [openGroup, setOpenGroup] = useState<GroupKey | null>(null);
|
||||
// PRESETS tab only: RECIPES chip expands the user-recipe strip above the main
|
||||
// row; CREATE chip opens the quick-input modal.
|
||||
const [showRecipes, setShowRecipes] = useState(false);
|
||||
const [createVisible, setCreateVisible] = useState(false);
|
||||
// WB tab: preset last tapped while its engine pair is still in effect (AUTO
|
||||
// vs DAYLIGHT share the neutral pair, so selection can't be derived).
|
||||
const [wbChoice, setWbChoice] = useState<string | null>(null);
|
||||
useEffect(() => {
|
||||
setOpenParam(null);
|
||||
setOpenGroup(null);
|
||||
setShowRecipes(false);
|
||||
setCreateVisible(false);
|
||||
}, [activeTab]);
|
||||
|
||||
const toggleParam = (key: string) => {
|
||||
haptic();
|
||||
setOpenParam((prev) => (prev === key ? null : key));
|
||||
onOpenParamChange(openParam === key ? null : key);
|
||||
};
|
||||
|
||||
const toggleGroup = (g: GroupKey) => {
|
||||
@@ -178,51 +204,49 @@ export default function AdjustmentPanel({
|
||||
);
|
||||
|
||||
// ---- per-tab parameter definitions ---------------------------------------
|
||||
// Static bounds/labels/accessors live in PARAM_DEFS (shared with App, which
|
||||
// needs the same metadata to drive drag-anywhere adjustment); here each row
|
||||
// is bound to the live adjustments value + an update callback.
|
||||
const toRows = (defs: ContinuousParamDef[]): ParamDef[] =>
|
||||
defs.map((d) => ({
|
||||
key: d.key,
|
||||
label: d.label,
|
||||
value: d.get(a),
|
||||
default: d.defaultValue,
|
||||
min: d.min,
|
||||
max: d.max,
|
||||
step: d.step,
|
||||
display: d.display,
|
||||
onChange: (v: number) => onUpdateAdjustments(d.set(v)),
|
||||
}));
|
||||
const paramDefs: Record<TabId, ParamDef[]> = {
|
||||
recipes: [],
|
||||
iq: [
|
||||
{ key: 'exposure', label: 'EXPOSURE', value: a.exposure, default: 0, min: -10, max: 10, step: 1, display: sign, onChange: (v) => onUpdateAdjustments({ exposure: v }) },
|
||||
{ key: 'ev', label: 'EV', value: a.exposureCompensation ?? 0, default: 0, min: -3, max: 3, step: 0.5, display: oneEv, onChange: (v) => onUpdateAdjustments({ exposureCompensation: v }) },
|
||||
{ key: 'contrast', label: 'CONTRAST', value: a.contrast, default: 0, min: -10, max: 10, step: 1, display: sign, onChange: (v) => onUpdateAdjustments({ contrast: v }) },
|
||||
{ key: 'color', label: 'COLOR', value: a.saturation, default: 0, min: -10, max: 10, step: 1, display: sign, onChange: (v) => onUpdateAdjustments({ saturation: v }) },
|
||||
{ key: 'highlight', label: 'HIGHLIGHT', value: a.highlight ?? 0, default: 0, min: -10, max: 10, step: 1, display: sign, onChange: (v) => onUpdateAdjustments({ highlight: v }) },
|
||||
{ key: 'shadow', label: 'SHADOW', value: a.shadow ?? 0, default: 0, min: -10, max: 10, step: 1, display: sign, onChange: (v) => onUpdateAdjustments({ shadow: v }) },
|
||||
],
|
||||
wb: [
|
||||
{ key: 'temperature', label: 'COLOR TEMP', value: a.temperature, default: 5500, min: 2500, max: 10000, step: 100, display: (v) => `${v}K`, onChange: (v) => onUpdateAdjustments({ temperature: v }) },
|
||||
{ key: 'tint', label: 'TINT', value: a.tint ?? 0, default: 0, min: -10, max: 10, step: 1, display: sign, onChange: (v) => onUpdateAdjustments({ tint: v }) },
|
||||
],
|
||||
filters: [
|
||||
{ key: 'denoise', label: 'NOISE REDUCTION', value: a.denoise, default: 0, min: 0, max: 10, step: 1, display: String, onChange: (v) => onUpdateAdjustments({ denoise: v }) },
|
||||
{ key: 'clarity', label: 'CLARITY', value: a.clarity, default: 0, min: -10, max: 10, step: 1, display: sign, onChange: (v) => onUpdateAdjustments({ clarity: v }) },
|
||||
{ key: 'sharpening', label: 'SHARPENING', value: a.sharpening ?? 0, default: 0, min: 0, max: 10, step: 1, display: String, onChange: (v) => onUpdateAdjustments({ sharpening: v }) },
|
||||
{ key: 'grain', label: 'MONOCHROME GRAIN', value: a.grain, default: 0, min: 0, max: 10, step: 1, display: String, onChange: (v) => onUpdateAdjustments({ grain: v }) },
|
||||
],
|
||||
iq: toRows(PARAM_DEFS.iq),
|
||||
wb: toRows(PARAM_DEFS.wb),
|
||||
filters: toRows(PARAM_DEFS.filters),
|
||||
frame: [],
|
||||
ratio: [],
|
||||
};
|
||||
|
||||
const wbPresetOf = (): string => {
|
||||
// Active WB chip: the remembered preset wins while the engine pair still
|
||||
// matches it; otherwise derive from the pair (neutral ⇒ AUTO; any unlisted
|
||||
// pair also reads as AUTO — no preset is a stronger claim there).
|
||||
const wbActive = (): string => {
|
||||
const t = a.temperature ?? 5500;
|
||||
const ti = a.tint ?? 0;
|
||||
if (t === 5500 && ti === -3) return 'daylight-3r';
|
||||
if (t === 6500 && ti === 1) return 'cloudy';
|
||||
if (t === 8000 && ti === 2) return 'shade';
|
||||
if (t === 3200 && ti === 0) return 'tungsten';
|
||||
if (t === 4000 && ti === 3) return 'fluorescent';
|
||||
if (t === 5500 && ti === 0) return 'daylight';
|
||||
return 'auto';
|
||||
if (wbChoice) {
|
||||
const p = WB_PAIRS[wbChoice];
|
||||
if (p && p.t === t && p.ti === ti) return wbChoice;
|
||||
}
|
||||
const hit = (Object.keys(WB_PAIRS) as string[]).find((k) => {
|
||||
const p = WB_PAIRS[k];
|
||||
return p.t === t && p.ti === ti;
|
||||
});
|
||||
return hit ?? 'auto';
|
||||
};
|
||||
const wbApply = (val: string) => {
|
||||
switch (val) {
|
||||
case 'daylight': return onUpdateAdjustments({ temperature: 5500, tint: 0 });
|
||||
case 'daylight-3r': return onUpdateAdjustments({ temperature: 5500, tint: -3 });
|
||||
case 'cloudy': return onUpdateAdjustments({ temperature: 6500, tint: 1 });
|
||||
case 'shade': return onUpdateAdjustments({ temperature: 8000, tint: 2 });
|
||||
case 'tungsten': return onUpdateAdjustments({ temperature: 3200, tint: 0 });
|
||||
case 'fluorescent': return onUpdateAdjustments({ temperature: 4000, tint: 3 });
|
||||
default: return onUpdateAdjustments({ temperature: 5500, tint: 0 });
|
||||
}
|
||||
setWbChoice(val);
|
||||
const p = WB_PAIRS[val] ?? WB_PAIRS.auto;
|
||||
onUpdateAdjustments({ temperature: p.t, tint: p.ti });
|
||||
};
|
||||
|
||||
// Choice-groups: one toggle chip sits in the chip row (COLOR CHROME /
|
||||
@@ -270,21 +294,29 @@ export default function AdjustmentPanel({
|
||||
const chipsRow = (() => {
|
||||
switch (activeTab) {
|
||||
case 'recipes': {
|
||||
if (recipes.length === 0) {
|
||||
return (
|
||||
<View className="px-3 py-2">
|
||||
<Text className="font-mono text-[10px] text-zinc-500">NO PRESETS - TAP SAVE ABOVE</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
const chips: ChipDef[] = recipes.map((r) => ({
|
||||
key: r.id,
|
||||
label: r.name,
|
||||
active: r.id === currentRecipeId,
|
||||
trash: !!r.isCustom,
|
||||
onPress: () => onSelectRecipe(r),
|
||||
onPressDelete: () => onDeleteRecipe(r.id),
|
||||
}));
|
||||
// Main row: the six standard film simulations, then the RECIPES toggle
|
||||
// (expands the user/bundled recipe strip above) and CREATE (quick-input
|
||||
// modal). Recipes themselves are no longer inline chips here.
|
||||
const chips: ChipDef[] = [
|
||||
...FILM_SIMS.map((s) => ({
|
||||
key: s.id,
|
||||
label: s.name,
|
||||
active: currentRecipeId === s.id,
|
||||
onPress: () => onSelectRecipe(filmSimToRecipe(s)),
|
||||
})),
|
||||
{
|
||||
key: 'recipes-toggle',
|
||||
label: 'RECIPES',
|
||||
active: showRecipes,
|
||||
onPress: () => setShowRecipes((v) => !v),
|
||||
},
|
||||
{
|
||||
key: 'create',
|
||||
label: 'CREATE',
|
||||
active: false,
|
||||
onPress: () => setCreateVisible(true),
|
||||
},
|
||||
];
|
||||
return (
|
||||
<ScrollView
|
||||
horizontal
|
||||
@@ -330,7 +362,7 @@ export default function AdjustmentPanel({
|
||||
{ v: 'tungsten', d: 'TUNGSTEN' },
|
||||
{ v: 'fluorescent', d: 'FLUOR' },
|
||||
],
|
||||
wbPresetOf(),
|
||||
wbActive(),
|
||||
wbApply
|
||||
)}
|
||||
{paramChips(paramDefs.wb)}
|
||||
@@ -372,36 +404,6 @@ export default function AdjustmentPanel({
|
||||
</ScrollView>
|
||||
);
|
||||
|
||||
case 'ratio':
|
||||
// Composition aspect (FULL = current fullscreen, 4:3 / 3:2 are the
|
||||
// upright photo ratios) + RAW DNG sidecar toggle (camera captures
|
||||
// only — dimmed in library mode where the shutter exports a still).
|
||||
return (
|
||||
<ScrollView
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
contentContainerStyle={{ alignItems: 'center', paddingHorizontal: 8, paddingVertical: 8 }}
|
||||
>
|
||||
{renderChip({
|
||||
key: 'raw',
|
||||
label: rawEnabled ? 'RAW ON' : 'RAW OFF',
|
||||
active: rawEnabled,
|
||||
disabled: !cameraMode,
|
||||
onPress: () => onRawEnabled(!rawEnabled),
|
||||
})}
|
||||
{choiceChips(
|
||||
'ratio',
|
||||
[
|
||||
{ v: 'full', d: 'FULL' },
|
||||
{ v: '4:3', d: '4:3' },
|
||||
{ v: '3:2', d: '3:2' },
|
||||
],
|
||||
aspectRatio,
|
||||
(v) => onAspectRatio(v as AspectRatio)
|
||||
)}
|
||||
</ScrollView>
|
||||
);
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
@@ -469,7 +471,47 @@ export default function AdjustmentPanel({
|
||||
</ScrollView>
|
||||
</View>
|
||||
)}
|
||||
{/* PRESETS: expanded recipe strip (above the main chip row) while the
|
||||
RECIPES chip is open — bundled defaults + user-created, custom
|
||||
deletable inline. */}
|
||||
{activeTab === 'recipes' && showRecipes && (
|
||||
<View className="border-b border-zinc-800/60">
|
||||
<ScrollView
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
contentContainerStyle={{ alignItems: 'center', paddingHorizontal: 8, paddingVertical: 6 }}
|
||||
>
|
||||
{recipes.length === 0
|
||||
? renderChip({
|
||||
key: 'no-recipes',
|
||||
label: 'NO RECIPES YET - TAP CREATE',
|
||||
disabled: true,
|
||||
active: false,
|
||||
onPress: () => {},
|
||||
})
|
||||
: recipes.map((r) =>
|
||||
renderChip({
|
||||
key: r.id,
|
||||
label: r.name,
|
||||
active: r.id === currentRecipeId,
|
||||
trash: !!r.isCustom,
|
||||
onPress: () => onSelectRecipe(r),
|
||||
onPressDelete: () => onDeleteRecipe(r.id),
|
||||
})
|
||||
)}
|
||||
</ScrollView>
|
||||
</View>
|
||||
)}
|
||||
{chipsRow}
|
||||
<RecipeCreateModal
|
||||
visible={createVisible}
|
||||
currentBase={currentBaseFilter}
|
||||
onClose={() => setCreateVisible(false)}
|
||||
onSave={(recipe) => {
|
||||
onCreateRecipe(recipe);
|
||||
setShowRecipes(true);
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import React from 'react';
|
||||
import { View, Text, TouchableOpacity, Image, StyleSheet } from 'react-native';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { Image as ImageIcon, Eye, Download, Camera } from 'lucide-react-native';
|
||||
import { Image as ImageIcon, Eye, Download, Camera, Settings } from 'lucide-react-native';
|
||||
|
||||
interface CameraControlsProps {
|
||||
lastPhotoUri: string | null;
|
||||
onCapture: () => void;
|
||||
onPickImage: () => void;
|
||||
onOpenPreview: () => void;
|
||||
onOpenSettings: () => void;
|
||||
isLibraryMode: boolean;
|
||||
onSwitchToCamera?: () => void;
|
||||
}
|
||||
@@ -17,6 +18,7 @@ export default function CameraControls({
|
||||
onCapture,
|
||||
onPickImage,
|
||||
onOpenPreview,
|
||||
onOpenSettings,
|
||||
isLibraryMode,
|
||||
onSwitchToCamera,
|
||||
}: CameraControlsProps) {
|
||||
@@ -27,14 +29,24 @@ export default function CameraControls({
|
||||
style={{ paddingBottom: insets.bottom + 10 }}
|
||||
className="flex-row items-center justify-between px-7 pt-2"
|
||||
>
|
||||
{/* Pick Image Button (left) */}
|
||||
<TouchableOpacity
|
||||
onPress={onPickImage}
|
||||
className="w-12 h-12 rounded-full bg-black/40 border border-zinc-700/70 flex items-center justify-center"
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<ImageIcon size={20} color="#e4e4e7" strokeWidth={1.75} />
|
||||
</TouchableOpacity>
|
||||
{/* Left cluster: Pick Image + gear (gear sits between library & shutter) */}
|
||||
<View className="flex-row items-center space-x-3">
|
||||
<TouchableOpacity
|
||||
onPress={onPickImage}
|
||||
className="w-12 h-12 rounded-full bg-black/40 border border-zinc-700/70 flex items-center justify-center"
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<ImageIcon size={20} color="#e4e4e7" strokeWidth={1.75} />
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
onPress={onOpenSettings}
|
||||
className="w-12 h-12 rounded-full bg-black/40 border border-zinc-700/70 flex items-center justify-center"
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Settings size={20} color="#e4e4e7" strokeWidth={1.75} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{/* Dynamic Center Button: Shutter or Save */}
|
||||
<TouchableOpacity
|
||||
@@ -60,9 +72,7 @@ export default function CameraControls({
|
||||
{/* Right Action Button: Eye (preview) or Camera (switch back) */}
|
||||
<TouchableOpacity
|
||||
onPress={isLibraryMode && onSwitchToCamera ? onSwitchToCamera : onOpenPreview}
|
||||
className={`w-12 h-12 rounded-full bg-black/40 border border-zinc-700/70 flex items-center justify-center overflow-hidden ${
|
||||
isLibraryMode ? '' : ''
|
||||
}`}
|
||||
className="w-12 h-12 rounded-full bg-black/40 border border-zinc-700/70 flex items-center justify-center overflow-hidden"
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
{isLibraryMode ? (
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { View, Text, Modal, Image, TouchableOpacity, ScrollView, Share, Alert, useWindowDimensions } from 'react-native';
|
||||
import * as Sharing from 'expo-sharing';
|
||||
import { X, Share2, Download, Trash2 } from 'lucide-react-native';
|
||||
import { PhotoEntry } from '../types';
|
||||
|
||||
// Session photo viewer: swipe left/right across every photo taken/exported in
|
||||
// this session. Top shows preset name + index/total; trash deletes the photo
|
||||
// being viewed (confirm modal first); share/save as before. Opened from the
|
||||
// thumbnail button (camera mode) or automatically after a library export —
|
||||
// NOT after a camera capture (that only fills the thumbnail).
|
||||
interface PhotoViewerModalProps {
|
||||
visible: boolean;
|
||||
photos: PhotoEntry[];
|
||||
initialIndex: number;
|
||||
onClose: () => void;
|
||||
// Remove photo id from the session history (App also deletes cache + gallery copy).
|
||||
onDelete: (id: string) => void;
|
||||
// Save the (unsaved) photo id to the gallery; App stores the resulting assetId.
|
||||
onSave: (id: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export default function PhotoViewerModal({
|
||||
visible,
|
||||
photos,
|
||||
initialIndex,
|
||||
onClose,
|
||||
onDelete,
|
||||
onSave,
|
||||
}: PhotoViewerModalProps) {
|
||||
const { width } = useWindowDimensions();
|
||||
const [index, setIndex] = useState(Math.max(0, Math.min(initialIndex, photos.length - 1)));
|
||||
const scrollRef = useRef<ScrollView>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const wasOpen = useRef(false);
|
||||
|
||||
// Opening the modal (or reopening on a different photo): jump to initialIndex.
|
||||
// Shrinks of the list while open (deletion) must NOT re-scroll — the strip
|
||||
// shifts left by one page and the same page slot keeps showing the next photo.
|
||||
useEffect(() => {
|
||||
if (!visible) {
|
||||
wasOpen.current = false;
|
||||
return;
|
||||
}
|
||||
if (!wasOpen.current) {
|
||||
wasOpen.current = true;
|
||||
if (photos.length > 0) {
|
||||
const i = Math.max(0, Math.min(initialIndex, photos.length - 1));
|
||||
setIndex(i);
|
||||
requestAnimationFrame(() => {
|
||||
scrollRef.current?.scrollTo({ x: i * width, animated: false });
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [visible, initialIndex, width, photos.length]);
|
||||
|
||||
// Keep the page state in bounds when the list shrinks underneath us.
|
||||
useEffect(() => {
|
||||
if (visible && photos.length > 0 && index >= photos.length) {
|
||||
const i = photos.length - 1;
|
||||
setIndex(i);
|
||||
scrollRef.current?.scrollTo({ x: i * width, animated: false });
|
||||
}
|
||||
}, [visible, index, photos.length, width]);
|
||||
|
||||
const photo = photos[index] || null;
|
||||
|
||||
const handleShare = async () => {
|
||||
if (!photo) return;
|
||||
try {
|
||||
if (await Sharing.isAvailableAsync()) {
|
||||
await Sharing.shareAsync(photo.uri, {
|
||||
mimeType: 'image/jpeg',
|
||||
dialogTitle: `Share with CamRecipe Pro - Preset: ${photo.recipeName}`,
|
||||
UTI: 'public.jpeg',
|
||||
});
|
||||
} else {
|
||||
await Share.share({
|
||||
url: photo.uri,
|
||||
message: `Captured with CamRecipe Pro - Preset: ${photo.recipeName}`,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Sharing failed:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeletePress = () => {
|
||||
if (!photo) return;
|
||||
if (photo.savedToLibrary) {
|
||||
// The gallery copy exists → Android itself shows its per-item delete
|
||||
// confirmation, so no app modal in front of it.
|
||||
onDelete(photo.id);
|
||||
return;
|
||||
}
|
||||
// Never reached the gallery — no system prompt will happen, keep an app
|
||||
// confirm as the only guard against an accidental trash tap.
|
||||
Alert.alert(
|
||||
'Delete Photo',
|
||||
'Delete this photo? It was never saved to your gallery.',
|
||||
[
|
||||
{ text: 'Cancel', style: 'cancel' },
|
||||
{ text: 'Delete', style: 'destructive', onPress: () => onDelete(photo.id) },
|
||||
]
|
||||
);
|
||||
};
|
||||
|
||||
const handleDone = async () => {
|
||||
if (!photo || photo.savedToLibrary || saving) {
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
await onSave(photo.id);
|
||||
onClose();
|
||||
} catch (e) {
|
||||
console.error('Save failed:', e);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal visible={visible} animationType="fade" transparent={false} onRequestClose={onClose}>
|
||||
<View className="flex-1 bg-black">
|
||||
{/* Top bar: preset + index/total (left), trash + close (right) */}
|
||||
<View className="pt-12 pb-2 px-4 flex-row justify-between items-center">
|
||||
<View className="flex-1 pr-2">
|
||||
<Text className="text-zinc-500 font-mono text-xs">
|
||||
{photo ? photo.recipeName.toUpperCase() : ''}
|
||||
</Text>
|
||||
<Text className="text-zinc-600 font-mono text-[10px] mt-0.5">
|
||||
{photos.length > 0 ? `${index + 1} / ${photos.length}` : ''}
|
||||
</Text>
|
||||
</View>
|
||||
<View className="flex-row items-center">
|
||||
{photo && (
|
||||
<TouchableOpacity
|
||||
onPress={handleDeletePress}
|
||||
className="w-9 h-9 rounded-full bg-zinc-900 flex items-center justify-center mr-2"
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Trash2 size={17} color="#ef4444" />
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
<TouchableOpacity
|
||||
onPress={onClose}
|
||||
className="w-9 h-9 rounded-full bg-zinc-900 flex items-center justify-center"
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<X size={18} color="#ffffff" />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Swipeable photo strip */}
|
||||
{photos.length > 0 ? (
|
||||
<ScrollView
|
||||
ref={scrollRef}
|
||||
horizontal
|
||||
pagingEnabled
|
||||
showsHorizontalScrollIndicator={false}
|
||||
onMomentumScrollEnd={(e) => {
|
||||
const i = Math.round(e.nativeEvent.contentOffset.x / width);
|
||||
setIndex(Math.max(0, Math.min(i, photos.length - 1)));
|
||||
}}
|
||||
>
|
||||
{photos.map((p) => (
|
||||
<View key={p.id} style={{ width }} className="justify-center items-center">
|
||||
<Image
|
||||
source={{ uri: p.uri }}
|
||||
style={{ width, height: '100%' }}
|
||||
className=""
|
||||
resizeMode="contain"
|
||||
/>
|
||||
</View>
|
||||
))}
|
||||
</ScrollView>
|
||||
) : (
|
||||
<View className="flex-1 items-center justify-center">
|
||||
<Text className="text-zinc-400 font-mono text-sm">NO PHOTOS</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Bottom bar */}
|
||||
<View className="pb-10 pt-3 px-4">
|
||||
{photo && !photo.savedToLibrary && (
|
||||
<Text className="text-amber-500 font-mono text-[10px] text-center mb-2">
|
||||
NOT SAVED TO GALLERY - SAVE & DONE TO KEEP
|
||||
</Text>
|
||||
)}
|
||||
<View className="flex-row justify-around items-center px-4">
|
||||
<TouchableOpacity
|
||||
onPress={handleShare}
|
||||
disabled={!photo}
|
||||
className="flex-row items-center bg-zinc-900 border border-zinc-800 px-6 py-3 rounded-full"
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Share2 size={16} color="#f59e0b" />
|
||||
<Text className="text-white font-mono text-xs font-bold ml-2">SHARE</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
onPress={handleDone}
|
||||
disabled={!photo || saving}
|
||||
className="flex-row items-center bg-amber-500 px-6 py-3 rounded-full"
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Download size={16} color="#000000" />
|
||||
<Text className="text-black font-mono text-xs font-bold ml-2">
|
||||
{saving ? 'SAVING...' : photo && !photo.savedToLibrary ? 'SAVE & DONE' : 'DONE'}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -1,153 +0,0 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { View, Text, Modal, Image, TouchableOpacity, Share, Alert } from 'react-native';
|
||||
import * as Sharing from 'expo-sharing';
|
||||
import * as MediaLibrary from 'expo-media-library/legacy';
|
||||
import { X, Share2, Download } from 'lucide-react-native';
|
||||
|
||||
interface PreviewModalProps {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
photoUri: string | null;
|
||||
recipeName: string;
|
||||
savedToLibrary: boolean; // whether the export was already saved to the gallery
|
||||
}
|
||||
|
||||
export default function PreviewModal({
|
||||
visible,
|
||||
onClose,
|
||||
photoUri,
|
||||
recipeName,
|
||||
savedToLibrary: initialSaved,
|
||||
}: PreviewModalProps) {
|
||||
const [saved, setSaved] = useState(initialSaved);
|
||||
|
||||
// Modal stays mounted — refresh state whenever a new export is shown.
|
||||
useEffect(() => {
|
||||
if (visible) setSaved(initialSaved);
|
||||
}, [visible, initialSaved]);
|
||||
|
||||
const handleShare = async () => {
|
||||
if (!photoUri) return;
|
||||
try {
|
||||
// expo-sharing actually shares the file; React Native's Share.share cannot
|
||||
// attach local files. Fall back to Share on platforms where it's unavailable.
|
||||
if (await Sharing.isAvailableAsync()) {
|
||||
await Sharing.shareAsync(photoUri, {
|
||||
mimeType: 'image/jpeg',
|
||||
dialogTitle: `Share with CamRecipe Pro - Preset: ${recipeName}`,
|
||||
UTI: 'public.jpeg',
|
||||
});
|
||||
} else {
|
||||
await Share.share({
|
||||
url: photoUri,
|
||||
message: `Captured with CamRecipe Pro - Preset: ${recipeName}`,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Sharing failed:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDone = async () => {
|
||||
if (!photoUri || saved) {
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const perm = await MediaLibrary.requestPermissionsAsync();
|
||||
if (!perm.granted) {
|
||||
Alert.alert(
|
||||
'Not Saved',
|
||||
'Media library access was denied, so this photo was not saved to your gallery. You can still share it from here.'
|
||||
);
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
await MediaLibrary.saveToLibraryAsync(photoUri);
|
||||
setSaved(true);
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error('Save to gallery failed:', error);
|
||||
Alert.alert('Save Failed', 'Could not save this photo to your gallery.');
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
if (photoUri && !saved) {
|
||||
Alert.alert(
|
||||
'Not Saved to Gallery',
|
||||
'This photo lives in the app cache only. Tap DONE to save a copy to your gallery, or SHARE it now.'
|
||||
);
|
||||
return;
|
||||
}
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
visible={visible}
|
||||
animationType="fade"
|
||||
transparent={false}
|
||||
onRequestClose={handleClose}
|
||||
>
|
||||
<View className="flex-1 bg-black justify-between py-6 px-4">
|
||||
{/* Top bar */}
|
||||
<View className="flex-row justify-between items-center px-2">
|
||||
<Text className="text-zinc-500 font-mono text-xs">PRESET: {recipeName.toUpperCase()}</Text>
|
||||
<TouchableOpacity
|
||||
onPress={handleClose}
|
||||
className="w-8 h-8 rounded-full bg-zinc-900 flex items-center justify-center"
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<X size={18} color="#ffffff" />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{/* Image display */}
|
||||
<View className="flex-1 justify-center items-center my-4">
|
||||
{photoUri ? (
|
||||
<Image
|
||||
source={{ uri: photoUri }}
|
||||
className="w-full aspect-[3/4] max-h-[75vh] rounded-lg border border-zinc-800"
|
||||
resizeMode="contain"
|
||||
/>
|
||||
) : (
|
||||
<Text className="text-zinc-400 font-mono text-sm">NO IMAGE CAPTURED</Text>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Bottom bar */}
|
||||
<View>
|
||||
{photoUri && !saved && (
|
||||
<Text className="text-amber-500 font-mono text-[10px] text-center mb-2">
|
||||
NOT SAVED TO GALLERY - TAP DONE TO SAVE
|
||||
</Text>
|
||||
)}
|
||||
<View className="flex-row justify-around items-center px-4">
|
||||
<TouchableOpacity
|
||||
onPress={handleShare}
|
||||
disabled={!photoUri}
|
||||
className="flex-row items-center space-x-2 bg-zinc-900 border border-zinc-800 px-6 py-3 rounded-full"
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Share2 size={16} color="#f59e0b" />
|
||||
<Text className="text-white font-mono text-xs font-bold">SHARE</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
onPress={handleDone}
|
||||
className="flex-row items-center space-x-2 bg-amber-500 px-6 py-3 rounded-full"
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Download size={16} color="#000000" />
|
||||
<Text className="text-black font-mono text-xs font-bold">
|
||||
{saved ? 'DONE' : 'SAVE & DONE'}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,417 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
TextInput,
|
||||
ScrollView,
|
||||
TouchableOpacity,
|
||||
Modal,
|
||||
KeyboardAvoidingView,
|
||||
Platform,
|
||||
} from 'react-native';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import * as Haptics from 'expo-haptics';
|
||||
import { Recipe, ColorAdjustments, BaseFilter } from '../types';
|
||||
import { DEFAULT_ADJUSTMENTS, FILM_SIMS } from '../utils/defaultRecipes';
|
||||
|
||||
// "CREATE" quick-input form shown above the viewfinder. Numeric knobs are
|
||||
// plain textboxes (typed fast, one row each), categorical knobs are chip
|
||||
// dropdowns. Used to enter Fuji-style recipes (DR/Highlight/Shadow/Color/NR/
|
||||
// Sharpness/Clarity/Grain/CC/CCB/WB+shift/ISO/EV) without touching sliders.
|
||||
|
||||
const haptic = () => Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||
|
||||
interface RecipeCreateModalProps {
|
||||
visible: boolean;
|
||||
// Id of the currently applied film sim (or bundled recipe whose look should
|
||||
// prefill the SIMULATION row); undefined → default to PROVIA.
|
||||
currentBase?: BaseFilter;
|
||||
onClose: () => void;
|
||||
onSave: (recipe: Omit<Recipe, 'id' | 'isCustom'>) => void;
|
||||
}
|
||||
|
||||
// WB presets mirror the WB-tab chips but keep pure presets (no -R variants —
|
||||
// those are the separate Red/Blue offset textboxes below).
|
||||
const WB_PRESETS: { key: string; label: string; temperature: number; tint: number }[] = [
|
||||
{ key: 'auto', label: 'AUTO', temperature: 5500, tint: 0 },
|
||||
{ key: 'daylight', label: 'DAYLIGHT', temperature: 5500, tint: 0 },
|
||||
{ key: 'cloudy', label: 'CLOUDY', temperature: 6500, tint: 1 },
|
||||
{ key: 'shade', label: 'SHADE', temperature: 8000, tint: 2 },
|
||||
{ key: 'tungsten', label: 'TUNGSTEN', temperature: 3200, tint: 0 },
|
||||
{ key: 'fluor', label: 'FLUOR', temperature: 4000, tint: 3 },
|
||||
];
|
||||
const WB_KEYS = WB_PRESETS.map((w) => w.key);
|
||||
|
||||
const DR_OPTS = [
|
||||
{ v: 'auto', d: 'AUTO' },
|
||||
{ v: '100', d: 'DR100' },
|
||||
{ v: '200', d: 'DR200' },
|
||||
{ v: '400', d: 'DR400' },
|
||||
] as const;
|
||||
const CC_OPTS = [
|
||||
{ v: 'none', d: 'OFF' },
|
||||
{ v: 'weak', d: 'WEAK' },
|
||||
{ v: 'strong', d: 'STRONG' },
|
||||
] as const;
|
||||
const GRAIN_OPTS = [
|
||||
{ v: '0', d: 'OFF' },
|
||||
{ v: '3', d: 'WEAK' },
|
||||
{ v: '6', d: 'STRONG' },
|
||||
] as const;
|
||||
|
||||
// ISO has no manual control path on this build (the vision-camera lib only
|
||||
// exposes exposure locking on iOS; Android CameraX keeps ISO automatic), so the
|
||||
// modal shows AUTO. If a native path ever appears, feed isoSupported=true and
|
||||
// the row below turns into a selectable chip list.
|
||||
const ISO_SUPPORTED = false;
|
||||
const ISO_STOPS = [200, 400, 800, 1600, 3200, 6400];
|
||||
|
||||
const clamp = (v: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, v));
|
||||
const int = (s: string, fallback: number, lo: number, hi: number) => {
|
||||
const n = parseInt(s, 10);
|
||||
return Number.isFinite(n) ? clamp(n, lo, hi) : fallback;
|
||||
};
|
||||
|
||||
interface ChipProps {
|
||||
label: string;
|
||||
active: boolean;
|
||||
onPress: () => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
const Chip = ({ label, active, onPress, disabled }: ChipProps) => (
|
||||
<TouchableOpacity
|
||||
onPress={() => { if (!disabled) { haptic(); onPress(); } }}
|
||||
className={`mx-0.5 rounded-full border px-2.5 py-1 ${
|
||||
disabled
|
||||
? 'border-zinc-800 bg-black/30'
|
||||
: active
|
||||
? 'border-amber-500/70 bg-amber-500/15'
|
||||
: 'border-zinc-700/60 bg-titan/80'
|
||||
}`}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Text
|
||||
className={`font-mono text-[10px] font-bold tracking-wide ${
|
||||
disabled ? 'text-zinc-600' : active ? 'text-amber-500' : 'text-zinc-300'
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
|
||||
const ChipRow = ({ children }: { children: React.ReactNode }) => (
|
||||
<ScrollView
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
contentContainerStyle={{ alignItems: 'center', paddingVertical: 2 }}
|
||||
>
|
||||
{children}
|
||||
</ScrollView>
|
||||
);
|
||||
|
||||
const LABEL_W = 132;
|
||||
|
||||
interface NumRowProps {
|
||||
label: string;
|
||||
value: string;
|
||||
onChange: (s: string) => void;
|
||||
placeholder?: string;
|
||||
width?: number;
|
||||
}
|
||||
const NumRow = ({ label, value, onChange, placeholder, width = 58 }: NumRowProps) => (
|
||||
<View style={{ width: '48%' }} className="mb-1.5">
|
||||
<Text className="text-zinc-400 font-mono text-[9px] font-bold tracking-widest mb-0.5">
|
||||
{label}
|
||||
</Text>
|
||||
<TextInput
|
||||
value={value}
|
||||
onChangeText={onChange}
|
||||
keyboardType="numbers-and-punctuation"
|
||||
placeholder={placeholder ?? '0'}
|
||||
placeholderTextColor="#52525b"
|
||||
className="bg-carbon border border-zinc-800 rounded-md px-2 py-1 text-white font-mono text-sm text-right"
|
||||
style={{ width }}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
|
||||
export default function RecipeCreateModal({
|
||||
visible,
|
||||
currentBase,
|
||||
onClose,
|
||||
onSave,
|
||||
}: RecipeCreateModalProps) {
|
||||
const insets = useSafeAreaInsets();
|
||||
// Seed from the current film sim (or PROVIA) so "other params" start at the
|
||||
// sim's standard values and the user only types what they want to change.
|
||||
const seedBase: BaseFilter =
|
||||
currentBase && FILM_SIMS.some((s) => s.baseFilter === currentBase) ? currentBase : 'provia';
|
||||
const seedAdj = FILM_SIMS.find((s) => s.baseFilter === seedBase)?.adjustments ?? DEFAULT_ADJUSTMENTS;
|
||||
|
||||
// Every sim's standard values as form state — used to seed the modal and to
|
||||
// reload the form when the SIMULATION chip changes.
|
||||
const valuesOf = (b: BaseFilter) => {
|
||||
const s = FILM_SIMS.find((x) => x.baseFilter === b)?.adjustments ?? DEFAULT_ADJUSTMENTS;
|
||||
return {
|
||||
dr: String(s.dynamicRange ?? 'auto'),
|
||||
grain: String(s.grain === 0 ? 0 : s.grain >= 6 ? 6 : 3),
|
||||
cc: s.colorChrome ?? 'none',
|
||||
ccb: s.colorChromeBlue ?? 'none',
|
||||
wbKey: WB_KEYS.find((k) => {
|
||||
const p = WB_PRESETS.find((w) => w.key === k)!;
|
||||
return p.temperature === s.temperature && p.tint === s.tint;
|
||||
}) ?? 'auto',
|
||||
highlight: String(s.highlight),
|
||||
shadow: String(s.shadow),
|
||||
color: String(s.saturation),
|
||||
nr: String(s.denoise),
|
||||
sharp: String(s.sharpening),
|
||||
clarity: String(s.clarity),
|
||||
ev: String(s.exposureCompensation),
|
||||
};
|
||||
};
|
||||
|
||||
const [sim, setSim] = useState<BaseFilter>(seedBase);
|
||||
const [name, setName] = useState('');
|
||||
const [dr, setDr] = useState<string>(seedAdj.dynamicRange == null ? 'auto' : String(seedAdj.dynamicRange));
|
||||
const [grain, setGrain] = useState<string>(String(seedAdj.grain === 0 ? 0 : seedAdj.grain >= 6 ? 6 : 3));
|
||||
const [cc, setCc] = useState<string>(seedAdj.colorChrome ?? 'none');
|
||||
const [ccb, setCcb] = useState<string>(seedAdj.colorChromeBlue ?? 'none');
|
||||
const [wbKey, setWbKey] = useState<string>(
|
||||
WB_KEYS.find((k) => {
|
||||
const p = WB_PRESETS.find((w) => w.key === k)!;
|
||||
return p.temperature === seedAdj.temperature && p.tint === seedAdj.tint;
|
||||
}) ?? 'auto'
|
||||
);
|
||||
const [wbRed, setWbRed] = useState('0');
|
||||
const [wbBlue, setWbBlue] = useState('0');
|
||||
const [fHigh, setFHigh] = useState(String(seedAdj.highlight));
|
||||
const [fShadow, setFShadow] = useState(String(seedAdj.shadow));
|
||||
const [fColor, setFColor] = useState(String(seedAdj.saturation));
|
||||
const [fNr, setFNr] = useState(String(seedAdj.denoise));
|
||||
const [fSharp, setFSharp] = useState(String(seedAdj.sharpening));
|
||||
const [fClarity, setFClarity] = useState(String(seedAdj.clarity));
|
||||
const [fEv, setFEv] = useState(String(seedAdj.exposureCompensation));
|
||||
const [iso, setIso] = useState<number | null>(null);
|
||||
|
||||
// Re-seed whenever the modal opens (or the applied look changes) so CREATE
|
||||
// quick-input always starts from the current simulation.
|
||||
useEffect(() => {
|
||||
if (!visible) return;
|
||||
const v = valuesOf(seedBase);
|
||||
setSim(seedBase);
|
||||
setDr(v.dr);
|
||||
setGrain(v.grain);
|
||||
setCc(v.cc);
|
||||
setCcb(v.ccb);
|
||||
setWbKey(v.wbKey);
|
||||
setWbRed('0');
|
||||
setWbBlue('0');
|
||||
setFHigh(v.highlight);
|
||||
setFShadow(v.shadow);
|
||||
setFColor(v.color);
|
||||
setFNr(v.nr);
|
||||
setFSharp(v.sharp);
|
||||
setFClarity(v.clarity);
|
||||
setFEv(v.ev);
|
||||
setName('');
|
||||
setIso(null);
|
||||
}, [visible, currentBase]);
|
||||
|
||||
// Switching the simulation reloads its standard values into the form.
|
||||
const selectSim = (b: BaseFilter) => {
|
||||
setSim(b);
|
||||
const v = valuesOf(b);
|
||||
setDr(v.dr);
|
||||
setGrain(v.grain);
|
||||
setCc(v.cc);
|
||||
setCcb(v.ccb);
|
||||
setWbKey(v.wbKey);
|
||||
setFHigh(v.highlight);
|
||||
setFShadow(v.shadow);
|
||||
setFColor(v.color);
|
||||
setFNr(v.nr);
|
||||
setFSharp(v.sharp);
|
||||
setFClarity(v.clarity);
|
||||
setFEv(v.ev);
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
const wb = WB_PRESETS.find((w) => w.key === wbKey) ?? WB_PRESETS[0];
|
||||
const simAdj = FILM_SIMS.find((s) => s.baseFilter === sim)?.adjustments ?? DEFAULT_ADJUSTMENTS;
|
||||
const adj: ColorAdjustments = {
|
||||
...DEFAULT_ADJUSTMENTS,
|
||||
...simAdj,
|
||||
temperature: wb.temperature,
|
||||
tint: wb.tint,
|
||||
wbRed: int(wbRed, 0, -9, 9),
|
||||
wbBlue: int(wbBlue, 0, -9, 9),
|
||||
highlight: int(fHigh, simAdj.highlight, -10, 10),
|
||||
shadow: int(fShadow, simAdj.shadow, -10, 10),
|
||||
saturation: int(fColor, simAdj.saturation, -10, 10),
|
||||
denoise: int(fNr, simAdj.denoise, 0, 10),
|
||||
sharpening: int(fSharp, simAdj.sharpening, 0, 10),
|
||||
clarity: int(fClarity, simAdj.clarity, -10, 10),
|
||||
grain: parseInt(grain, 10),
|
||||
colorChrome: cc as ColorAdjustments['colorChrome'],
|
||||
colorChromeBlue: ccb as ColorAdjustments['colorChromeBlue'],
|
||||
dynamicRange: (dr === 'auto' ? 'auto' : parseInt(dr, 10)) as ColorAdjustments['dynamicRange'],
|
||||
exposureCompensation: int(fEv, simAdj.exposureCompensation, -3, 3),
|
||||
};
|
||||
onSave({
|
||||
name: name.trim() || 'NEW RECIPE',
|
||||
baseFilter: sim,
|
||||
adjustments: adj,
|
||||
frameId: 'none',
|
||||
useGeotag: true,
|
||||
});
|
||||
setName('');
|
||||
onClose();
|
||||
};
|
||||
|
||||
const fieldSets: { label: string; value: string; set: (s: string) => void; ph?: string }[] = [
|
||||
{ label: 'HIGHLIGHT', value: fHigh, set: setFHigh },
|
||||
{ label: 'SHADOW', value: fShadow, set: setFShadow },
|
||||
{ label: 'COLOR', value: fColor, set: setFColor },
|
||||
{ label: 'NOISE RED.', value: fNr, set: setFNr, ph: '0-10' },
|
||||
{ label: 'SHARPENING', value: fSharp, set: setFSharp, ph: '0-10' },
|
||||
{ label: 'CLARITY', value: fClarity, set: setFClarity },
|
||||
{ label: 'EXPOSURE COMP.', value: fEv, set: setFEv, ph: 'EV' },
|
||||
];
|
||||
|
||||
return (
|
||||
<Modal transparent animationType="slide" visible={visible} onRequestClose={onClose}>
|
||||
<View className="absolute inset-0 bg-black/60" />
|
||||
<KeyboardAvoidingView style={{ flex: 1 }} behavior={Platform.OS === 'ios' ? 'padding' : 'height'}>
|
||||
<View
|
||||
style={{ paddingTop: insets.top + 12, paddingBottom: insets.bottom + 12, maxHeight: '100%' }}
|
||||
className="bg-titan border-b border-zinc-800 rounded-b-3xl px-4 pt-1"
|
||||
>
|
||||
<View className="flex-row items-center justify-between mb-2">
|
||||
<Text className="text-white font-mono text-sm font-bold tracking-wider">CREATE RECIPE</Text>
|
||||
<TouchableOpacity onPress={onClose} className="px-2 py-1" activeOpacity={0.7}>
|
||||
<Text className="text-zinc-400 font-mono text-xs font-bold">✕</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<ScrollView showsVerticalScrollIndicator={false} contentContainerStyle={{ paddingBottom: 8 }}>
|
||||
{/* Name */}
|
||||
<Text className="text-zinc-400 font-mono text-[9px] font-bold tracking-widest mb-0.5">NAME</Text>
|
||||
<TextInput
|
||||
value={name}
|
||||
onChangeText={setName}
|
||||
placeholder="Recipe name (e.g. Fuji Retro)"
|
||||
placeholderTextColor="#71717a"
|
||||
className="bg-carbon border border-zinc-800 rounded-lg p-2 text-white font-mono text-sm mb-2"
|
||||
/>
|
||||
|
||||
{/* Simulation */}
|
||||
<Text className="text-zinc-400 font-mono text-[9px] font-bold tracking-widest mb-0.5">SIMULATION</Text>
|
||||
<ChipRow>
|
||||
{FILM_SIMS.map((s) => (
|
||||
<Chip key={s.id} label={s.name} active={sim === s.baseFilter} onPress={() => selectSim(s.baseFilter)} />
|
||||
))}
|
||||
</ChipRow>
|
||||
|
||||
{/* Dynamic range */}
|
||||
<Text className="text-zinc-400 font-mono text-[9px] font-bold tracking-widest mt-1.5 mb-0.5">DYNAMIC RANGE</Text>
|
||||
<ChipRow>
|
||||
{DR_OPTS.map((o) => (
|
||||
<Chip key={o.v} label={o.d} active={dr === o.v} onPress={() => setDr(o.v)} />
|
||||
))}
|
||||
</ChipRow>
|
||||
|
||||
{/* Numeric textboxes — 2 per row */}
|
||||
<View className="flex-row flex-wrap justify-between mt-1.5">
|
||||
{fieldSets.map((f) => (
|
||||
<NumRow key={f.label} label={f.label} value={f.value} onChange={f.set} placeholder={f.ph} />
|
||||
))}
|
||||
</View>
|
||||
|
||||
{/* Grain / chrome */}
|
||||
<View className="mt-1">
|
||||
<Text className="text-zinc-400 font-mono text-[9px] font-bold tracking-widest mb-0.5">GRAIN EFFECT</Text>
|
||||
<ChipRow>
|
||||
{GRAIN_OPTS.map((o) => (
|
||||
<Chip key={o.v} label={o.d} active={grain === o.v} onPress={() => setGrain(o.v)} />
|
||||
))}
|
||||
</ChipRow>
|
||||
<Text className="text-zinc-400 font-mono text-[9px] font-bold tracking-widest mt-1.5 mb-0.5">COLOR CHROME EFFECT</Text>
|
||||
<ChipRow>
|
||||
{CC_OPTS.map((o) => (
|
||||
<Chip key={o.v} label={o.d} active={cc === o.v} onPress={() => setCc(o.v)} />
|
||||
))}
|
||||
</ChipRow>
|
||||
<Text className="text-zinc-400 font-mono text-[9px] font-bold tracking-widest mt-1.5 mb-0.5">COLOR CHROME EFFECT BLUE</Text>
|
||||
<ChipRow>
|
||||
{CC_OPTS.map((o) => (
|
||||
<Chip key={o.v} label={o.d} active={ccb === o.v} onPress={() => setCcb(o.v)} />
|
||||
))}
|
||||
</ChipRow>
|
||||
</View>
|
||||
|
||||
{/* White balance + R/B shift */}
|
||||
<Text className="text-zinc-400 font-mono text-[9px] font-bold tracking-widest mt-1.5 mb-0.5">WHITE BALANCE</Text>
|
||||
<ChipRow>
|
||||
{WB_PRESETS.map((w) => (
|
||||
<Chip key={w.key} label={w.label} active={wbKey === w.key} onPress={() => setWbKey(w.key)} />
|
||||
))}
|
||||
</ChipRow>
|
||||
<View className="flex-row items-center mt-1">
|
||||
<Text style={{ width: LABEL_W }} className="text-zinc-400 font-mono text-[9px] font-bold tracking-widest">
|
||||
WB SHIFT
|
||||
</Text>
|
||||
<Text className="text-zinc-500 font-mono text-[9px] mr-1.5">RED</Text>
|
||||
<TextInput
|
||||
value={wbRed}
|
||||
onChangeText={setWbRed}
|
||||
keyboardType="numbers-and-punctuation"
|
||||
placeholder="-9..9"
|
||||
placeholderTextColor="#52525b"
|
||||
className="bg-carbon border border-zinc-800 rounded-md px-2 py-1 text-white font-mono text-sm text-right mr-3"
|
||||
style={{ width: 52 }}
|
||||
/>
|
||||
<Text className="text-zinc-500 font-mono text-[9px] mr-1.5">BLUE</Text>
|
||||
<TextInput
|
||||
value={wbBlue}
|
||||
onChangeText={setWbBlue}
|
||||
keyboardType="numbers-and-punctuation"
|
||||
placeholder="-9..9"
|
||||
placeholderTextColor="#52525b"
|
||||
className="bg-carbon border border-zinc-800 rounded-md px-2 py-1 text-white font-mono text-sm text-right"
|
||||
style={{ width: 52 }}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* ISO */}
|
||||
<View className="flex-row items-center mt-1.5 mb-1">
|
||||
<Text style={{ width: LABEL_W }} className="text-zinc-400 font-mono text-[9px] font-bold tracking-widest">
|
||||
ISO
|
||||
</Text>
|
||||
{ISO_SUPPORTED ? (
|
||||
<ChipRow>
|
||||
<Chip label="AUTO" active={iso === null} onPress={() => setIso(null)} />
|
||||
{ISO_STOPS.map((s) => (
|
||||
<Chip key={s} label={String(s)} active={iso === s} onPress={() => setIso(s)} />
|
||||
))}
|
||||
</ChipRow>
|
||||
) : (
|
||||
<Chip label="AUTO (camera default)" active disabled onPress={() => {}} />
|
||||
)}
|
||||
</View>
|
||||
</ScrollView>
|
||||
|
||||
<View className="flex-row space-x-3 mt-2">
|
||||
<TouchableOpacity onPress={onClose} className="flex-1 bg-zinc-800 py-3 rounded-lg items-center" activeOpacity={0.7}>
|
||||
<Text className="text-zinc-300 font-mono text-xs font-semibold">CANCEL</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity onPress={handleSave} className="flex-1 bg-amber-500 py-3 rounded-lg items-center" activeOpacity={0.7}>
|
||||
<Text className="text-black font-mono text-xs font-bold">SAVE RECIPE</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
</KeyboardAvoidingView>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
import React from 'react';
|
||||
import { View, Text, Modal, TouchableOpacity } from 'react-native';
|
||||
import * as Haptics from 'expo-haptics';
|
||||
import { X, Volume2, Settings as SettingsIcon } from 'lucide-react-native';
|
||||
import { AspectRatio, MeterMode, ShutterSound } from '../types';
|
||||
|
||||
// Gear-sheet (gear button between library picker and shutter). Groups the
|
||||
// capture/composition choices that are NOT look-adjustments, so the tool rail
|
||||
// keeps only the image tools: shutter sound, composition ratio + RAW DNG
|
||||
// sidecar, and full-frame metering mode (ex-FOCUS tab → METERING MODE).
|
||||
interface Chip {
|
||||
key: string;
|
||||
label: string;
|
||||
active: boolean;
|
||||
onPress: () => void;
|
||||
disabled?: boolean; // informational chip, not tappable (CAMERA ONLY etc.)
|
||||
}
|
||||
|
||||
const haptic = () => Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||
|
||||
interface SettingsModalProps {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
aspectRatio: AspectRatio;
|
||||
onAspectRatio: (ratio: AspectRatio) => void;
|
||||
rawEnabled: boolean;
|
||||
onRawEnabled: (enabled: boolean) => void;
|
||||
cameraMode: boolean;
|
||||
meterMode: MeterMode;
|
||||
onMeterModeChange: (mode: MeterMode) => void;
|
||||
shutterSound: ShutterSound;
|
||||
onShutterSound: (sound: ShutterSound) => void;
|
||||
}
|
||||
|
||||
function ChipRow({ chips }: { chips: Chip[] }) {
|
||||
return (
|
||||
<View className="flex-row flex-wrap items-center">
|
||||
{chips.map((c) =>
|
||||
c.disabled ? (
|
||||
<View
|
||||
key={c.key}
|
||||
className="mx-1 flex-row items-center rounded-full border border-zinc-800/80 bg-black/30 px-3 py-1.5"
|
||||
>
|
||||
<Text className="font-mono text-[11px] font-semibold text-zinc-500">{c.label}</Text>
|
||||
</View>
|
||||
) : (
|
||||
<TouchableOpacity
|
||||
key={c.key}
|
||||
onPress={() => { haptic(); c.onPress(); }}
|
||||
activeOpacity={0.7}
|
||||
className={`mx-1 flex-row items-center rounded-full border px-3 py-1.5 ${
|
||||
c.active ? 'border-amber-500/70 bg-amber-500/15' : 'border-zinc-700/60 bg-titan/80'
|
||||
}`}
|
||||
>
|
||||
<Text className={`font-mono text-[11px] font-bold tracking-wide ${c.active ? 'text-amber-500' : 'text-zinc-300'}`}>
|
||||
{c.label}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
)
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function SectionLabel({ children }: { children: string }) {
|
||||
return (
|
||||
<Text className="text-zinc-500 font-mono text-[10px] tracking-[0.2em] mb-2">{children}</Text>
|
||||
);
|
||||
}
|
||||
|
||||
function CameraOnlyNote({ cameraMode }: { cameraMode: boolean }) {
|
||||
if (cameraMode) return null;
|
||||
return <Text className="text-zinc-600 font-mono text-[10px] mt-1.5 ml-1">CAMERA ONLY</Text>;
|
||||
}
|
||||
|
||||
export default function SettingsModal({
|
||||
visible,
|
||||
onClose,
|
||||
aspectRatio,
|
||||
onAspectRatio,
|
||||
rawEnabled,
|
||||
onRawEnabled,
|
||||
cameraMode,
|
||||
meterMode,
|
||||
onMeterModeChange,
|
||||
shutterSound,
|
||||
onShutterSound,
|
||||
}: SettingsModalProps) {
|
||||
return (
|
||||
<Modal visible={visible} transparent animationType="fade" onRequestClose={onClose}>
|
||||
<View className="flex-1 bg-black/70 items-center justify-center px-6">
|
||||
<TouchableOpacity
|
||||
className="absolute inset-0"
|
||||
activeOpacity={1}
|
||||
onPress={onClose}
|
||||
accessibilityLabel="Close settings"
|
||||
/>
|
||||
<View
|
||||
className="w-full max-w-md rounded-2xl border border-zinc-800/70 p-5 pb-6 space-y-5"
|
||||
style={{ backgroundColor: 'rgba(8,8,10,0.97)' }}
|
||||
>
|
||||
<View className="flex-row items-center justify-between px-1">
|
||||
<View className="flex-row items-center">
|
||||
<SettingsIcon size={16} color="#a1a1aa" strokeWidth={1.75} />
|
||||
<Text className="text-zinc-400 font-mono text-xs font-bold tracking-widest ml-2">
|
||||
SETTINGS
|
||||
</Text>
|
||||
</View>
|
||||
<TouchableOpacity
|
||||
onPress={onClose}
|
||||
className="w-8 h-8 rounded-full bg-zinc-900 flex items-center justify-center"
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<X size={16} color="#ffffff" />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{/* Shutter sound */}
|
||||
<View>
|
||||
<SectionLabel>SHUTTER SOUND</SectionLabel>
|
||||
<ChipRow
|
||||
chips={[
|
||||
{ key: 'snd-on', label: 'ON', active: shutterSound === 'on', onPress: () => onShutterSound('on') },
|
||||
{ key: 'snd-off', label: 'OFF', active: shutterSound === 'off', onPress: () => onShutterSound('off') },
|
||||
]}
|
||||
/>
|
||||
<View className="flex-row items-center mt-1.5 ml-1">
|
||||
<Volume2 size={11} color="#52525b" strokeWidth={1.75} />
|
||||
<Text className="text-zinc-600 font-mono text-[10px] ml-1.5">
|
||||
Android has no click-volume control — OFF only. Some regions force the sound on by OS policy.
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Composition ratio + RAW */}
|
||||
<View>
|
||||
<SectionLabel>PHOTO RATIO</SectionLabel>
|
||||
<ChipRow
|
||||
chips={[
|
||||
{ key: 'ratio-full', label: 'FULL', active: aspectRatio === 'full', onPress: () => onAspectRatio('full') },
|
||||
{ key: 'ratio-4:3', label: '4:3', active: aspectRatio === '4:3', onPress: () => onAspectRatio('4:3') },
|
||||
{ key: 'ratio-3:2', label: '3:2', active: aspectRatio === '3:2', onPress: () => onAspectRatio('3:2') },
|
||||
]}
|
||||
/>
|
||||
<View className="mt-2">
|
||||
<ChipRow
|
||||
chips={[
|
||||
cameraMode
|
||||
? { key: 'raw', label: rawEnabled ? 'RAW DNG ON' : 'RAW DNG OFF', active: rawEnabled, onPress: () => onRawEnabled(!rawEnabled) }
|
||||
: { key: 'raw', label: rawEnabled ? 'RAW DNG ON' : 'RAW DNG OFF', active: rawEnabled, disabled: true, onPress: () => {} },
|
||||
]}
|
||||
/>
|
||||
<CameraOnlyNote cameraMode={cameraMode} />
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Metering mode */}
|
||||
<View>
|
||||
<SectionLabel>METERING MODE</SectionLabel>
|
||||
<ChipRow
|
||||
chips={[
|
||||
cameraMode
|
||||
? { key: 'meter-eval', label: 'EVALUATIVE', active: meterMode === 'evaluative', onPress: () => onMeterModeChange('evaluative') }
|
||||
: { key: 'meter-eval', label: 'EVALUATIVE', active: meterMode === 'evaluative', disabled: true, onPress: () => {} },
|
||||
cameraMode
|
||||
? { key: 'meter-hl', label: 'HIGHLIGHT-WEIGHTED', active: meterMode === 'highlight', onPress: () => onMeterModeChange('highlight') }
|
||||
: { key: 'meter-hl', label: 'HIGHLIGHT-WEIGHTED', active: meterMode === 'highlight', disabled: true, onPress: () => {} },
|
||||
]}
|
||||
/>
|
||||
<CameraOnlyNote cameraMode={cameraMode} />
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
import React from 'react';
|
||||
import { View, Text, TouchableOpacity, ScrollView } from 'react-native';
|
||||
import { LayoutGrid, Sun, Thermometer, Sparkles, Frame, Ratio, type LucideIcon } from 'lucide-react-native';
|
||||
import { LayoutGrid, Sun, Thermometer, Sparkles, Frame, type LucideIcon } from 'lucide-react-native';
|
||||
import * as Haptics from 'expo-haptics';
|
||||
|
||||
export type TabId = 'recipes' | 'iq' | 'wb' | 'filters' | 'frame' | 'ratio';
|
||||
export type TabId = 'recipes' | 'iq' | 'wb' | 'filters' | 'frame';
|
||||
|
||||
interface ToolRailProps {
|
||||
activeTab: TabId | null;
|
||||
@@ -16,7 +16,6 @@ const TOOLS: { id: TabId; label: string; icon: LucideIcon }[] = [
|
||||
{ id: 'wb', label: 'WB', icon: Thermometer },
|
||||
{ id: 'filters', label: 'FX', icon: Sparkles },
|
||||
{ id: 'frame', label: 'FRAME', icon: Frame },
|
||||
{ id: 'ratio', label: 'RATIO', icon: Ratio },
|
||||
];
|
||||
|
||||
export default function ToolRail({ activeTab, onToggle }: ToolRailProps) {
|
||||
|
||||
@@ -23,7 +23,7 @@ import {
|
||||
MipmapMode,
|
||||
} from '@shopify/react-native-skia';
|
||||
import { Lock } from 'lucide-react-native';
|
||||
import { Recipe, GPSInfo, FrameId, AspectRatio, ASPECT_RATIO_W_H } from '../types';
|
||||
import { Recipe, GPSInfo, FrameId, AspectRatio, ColorAdjustments, ASPECT_RATIO_W_H } from '../types';
|
||||
import { getSkiaColorMatrix, applyExposureGain } from '../utils/colorUtils';
|
||||
import { TONE_SKSL, getToneUniforms } from '../utils/toneShader';
|
||||
import { CINEMA_SKSL, getCinemaUniforms, cinemaOffFlat } from '../utils/cinemaShader';
|
||||
@@ -52,6 +52,31 @@ interface ViewfinderProps {
|
||||
onSessionError?: (error: Error) => void;
|
||||
// Quick exposure slider (shown while AE/AF is locked) — live AE bias in EV.
|
||||
onExposureChange?: (ev: number) => void;
|
||||
// Metering-mode EV offset (highlight-weighted): extra EV stops ADDED on top
|
||||
// of the recipe EV. Applied to the preview matrix AND the capture-time AE
|
||||
// flush so preview/export/capture agree; the EV slider UI keeps showing the
|
||||
// raw user EV. Negative = protect highlights. 0 = off.
|
||||
meteringEvOffset?: number;
|
||||
// Metering-mode tonal look (highlight-weighted): small extra adjustments
|
||||
// merged on top of the recipe for RENDERING only (preview matrix + tone, and
|
||||
// App mirrors it into the export). Real highlight-weighted metering (GR /
|
||||
// Sony) doesn't only underexpose — it meters for the BRIGHTEST area to keep
|
||||
// its detail (absolute no-blowout): highlight roll, deep shadow crush, raised
|
||||
// contrast/clarity. Never contains exposureCompensation (that rides the EV
|
||||
// offset above); UI sliders keep showing the raw recipe.
|
||||
meteringAdjustments?: Partial<ColorAdjustments>;
|
||||
// Continuous panel parameter whose slider row is open (App lifts it from the
|
||||
// AdjustmentPanel). While set, a vertical drag ANYWHERE on the live image
|
||||
// adjusts this parameter (up = increase) — no need to grab the small slider.
|
||||
// Null while no param row is open; then a locked-AE drag adjusts EV instead.
|
||||
imageAdjustTarget?: {
|
||||
key: string;
|
||||
min: number;
|
||||
max: number;
|
||||
step: number;
|
||||
value: number;
|
||||
onChange: (v: number) => void;
|
||||
} | null;
|
||||
}
|
||||
|
||||
export interface ViewfinderHandle {
|
||||
@@ -81,7 +106,7 @@ const toneEffect = (() => {
|
||||
try {
|
||||
return Skia.RuntimeEffect.Make(TONE_SKSL);
|
||||
} catch (e) {
|
||||
console.error('[camtone] TONE_SKSL compile failed: ' + e);
|
||||
console.error('TONE_SKSL compile failed: ' + e);
|
||||
return null;
|
||||
}
|
||||
})();
|
||||
@@ -90,7 +115,7 @@ const cinemaEffect = (() => {
|
||||
try {
|
||||
return Skia.RuntimeEffect.Make(CINEMA_SKSL);
|
||||
} catch (e) {
|
||||
console.error('[cinema] CINEMA_SKSL compile failed: ' + e);
|
||||
console.error('CINEMA_SKSL compile failed: ' + e);
|
||||
return null;
|
||||
}
|
||||
})();
|
||||
@@ -118,6 +143,9 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
|
||||
rawOutput,
|
||||
onSessionError,
|
||||
onExposureChange,
|
||||
meteringEvOffset = 0,
|
||||
meteringAdjustments,
|
||||
imageAdjustTarget,
|
||||
}: ViewfinderProps,
|
||||
ref
|
||||
) {
|
||||
@@ -212,14 +240,20 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
|
||||
Math.round(vw * 0.032)
|
||||
);
|
||||
|
||||
const adjustments = recipe.adjustments;
|
||||
const adjustments = meteringAdjustments
|
||||
? { ...recipe.adjustments, ...meteringAdjustments }
|
||||
: recipe.adjustments;
|
||||
// Exposure compensation, applied as software 2^EV matrix gain for BOTH modes.
|
||||
// Camera AE bias is only flushed to hardware at capture time
|
||||
// (flushExposureBias): setting it live per slider value re-locks AE on the
|
||||
// Xiaomi and flashes the preview (matrix change is instant and flicker-free).
|
||||
// Metering offset (highlight-weighted) rides on top of the user EV so the
|
||||
// preview shows the protected-exposure look; EV_RANGE ±3 caps the software
|
||||
// gain (flush clamps to the device range separately).
|
||||
const evApplied = Math.max(-3, Math.min(3, (adjustments.exposureCompensation ?? 0) + meteringEvOffset));
|
||||
const colorMatrix = applyExposureGain(
|
||||
getSkiaColorMatrix(recipe.baseFilter, adjustments),
|
||||
adjustments.exposureCompensation ?? 0
|
||||
evApplied
|
||||
);
|
||||
|
||||
// The Skia onFrame worklet runs on a separate runtime — keep the latest color
|
||||
@@ -259,6 +293,15 @@ 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);
|
||||
// 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(() => {
|
||||
setCameraActive(true);
|
||||
}, []);
|
||||
const handleCameraStopped = useCallback(() => {
|
||||
setCameraActive(false);
|
||||
}, []);
|
||||
// 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');
|
||||
@@ -275,15 +318,16 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
|
||||
// per intermediate value — each one cancelled by the next, spamming [ev]).
|
||||
const evStopsRef = useRef(evStops);
|
||||
evStopsRef.current = evStops;
|
||||
// Metering offset mirrored into a ref: flushExposureBias is a stable
|
||||
// imperative handle (useImperativeHandle deps [ref]) and must read the LIVE
|
||||
// offset when the user switches metering mode.
|
||||
const meteringEvOffsetRef = useRef(meteringEvOffset);
|
||||
meteringEvOffsetRef.current = meteringEvOffset;
|
||||
useEffect(() => {
|
||||
if (!controller) return;
|
||||
const d = controller.device;
|
||||
if (d && d.supportsExposureBias) {
|
||||
console.error(
|
||||
`[ev] device range=${d.minExposureBias}..${d.maxExposureBias} current=${controller.exposureBias}`
|
||||
);
|
||||
} else {
|
||||
console.error(`[ev] device has no exposure bias support`);
|
||||
if (d && !d.supportsExposureBias) {
|
||||
console.warn('Device has no exposure-bias support — EV applies to preview and export only.');
|
||||
}
|
||||
}, [controller]);
|
||||
// Hardware AE bias is applied ONLY at capture time via flushExposureBias()
|
||||
@@ -300,17 +344,16 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
|
||||
const ctl = skiaCameraRef.current?.controller;
|
||||
const d = ctl?.device;
|
||||
if (!ctl || !d || !d.supportsExposureBias) return;
|
||||
const ev = evStopsRef.current;
|
||||
const ev = evStopsRef.current + meteringEvOffsetRef.current;
|
||||
const index = Math.round(ev * 8);
|
||||
const clamped = Math.max(d.minExposureBias, Math.min(d.maxExposureBias, index));
|
||||
if (clamped === ctl.exposureBias) return;
|
||||
for (let attempt = 1; attempt <= 3; attempt++) {
|
||||
try {
|
||||
await ctl.setExposureBias(clamped);
|
||||
console.error(`[ev] flush ev=${ev} idx=${clamped} ok, read=${ctl.exposureBias}`);
|
||||
return;
|
||||
} catch (e) {
|
||||
console.error(`[ev] flush ev=${ev} idx=${clamped} attempt=${attempt} failed: ${String(e).slice(0, 100)}`);
|
||||
console.warn(`Exposure-bias flush failed (attempt ${attempt}): ${String(e).slice(0, 100)}`);
|
||||
await new Promise((r) => setTimeout(r, 80));
|
||||
}
|
||||
}
|
||||
@@ -330,7 +373,7 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
|
||||
const d = cam?.controller?.device;
|
||||
if (!d) return;
|
||||
if (!d.supportsFocusMetering && !d.supportsExposureMetering && !d.supportsWhiteBalanceMetering) {
|
||||
console.error('[focus] metering unsupported on this device');
|
||||
console.warn('Focus/exposure metering unsupported on this device');
|
||||
return;
|
||||
}
|
||||
setFocusPoint({ x, y });
|
||||
@@ -339,9 +382,8 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
|
||||
{ x, y },
|
||||
{ responsiveness: 'snappy', adaptiveness: 'locked', autoResetAfter: null }
|
||||
);
|
||||
console.error(`[focus] tap=${Math.round(x)},${Math.round(y)} AE/AF/AWB locked`);
|
||||
} catch (e) {
|
||||
console.error('[focus] lock failed: ' + String(e).slice(0, 120));
|
||||
console.warn('Focus lock failed: ' + String(e).slice(0, 120));
|
||||
}
|
||||
};
|
||||
const handleUnlockFocus = async () => {
|
||||
@@ -350,9 +392,8 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
|
||||
try {
|
||||
await cam.resetFocus();
|
||||
setFocusPoint(null);
|
||||
console.error('[focus] reset to continuous auto');
|
||||
} catch (e) {
|
||||
console.error('[focus] reset failed: ' + String(e).slice(0, 120));
|
||||
console.warn('Focus reset failed: ' + String(e).slice(0, 120));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -365,41 +406,114 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
|
||||
const evPxPerStop = evTrackH / (2 * EV_RANGE);
|
||||
const clampEv = (v: number) => Math.max(-EV_RANGE, Math.min(EV_RANGE, v));
|
||||
const evDragRef = useRef<{ y0: number; ev0: number } | null>(null);
|
||||
// Last EV value actually pushed to App. Quick-EV drags otherwise fire a full
|
||||
// adjustments re-render on EVERY pointer move (30-60Hz), even while the
|
||||
// finger holds a step without the value changing — that render storm stalls
|
||||
// the main thread and beats the Skia preview. Only push real nấc changes.
|
||||
const evLastSentRef = useRef<number | null>(null);
|
||||
const evThumbY = evTrackTop + ((EV_RANGE - clampEv(evStops)) / (2 * EV_RANGE)) * evTrackH;
|
||||
// Screen-absolute Y of the gesture. locationY is relative to whichever view
|
||||
// the finger currently hit-tests over, so crossing the small track's edge
|
||||
// mid-drag changed the origin by the track offset and snapped EV by ~3 stops
|
||||
// (the "jumps to the other end" bug). pageY never moves.
|
||||
const pageYOf = (e: any): number => {
|
||||
const t = e?.nativeEvent?.touches?.[0];
|
||||
if (t && typeof t.pageY === 'number') return t.pageY;
|
||||
const p = e?.nativeEvent?.pageY;
|
||||
if (typeof p === 'number') return p;
|
||||
return e?.nativeEvent?.locationY ?? 0;
|
||||
};
|
||||
const onEvSliderGrant = (e: any) => {
|
||||
evDragRef.current = { y0: e.nativeEvent.locationY, ev0: evStops };
|
||||
const y0 = pageYOf(e);
|
||||
evDragRef.current = { y0, ev0: evStops };
|
||||
evLastSentRef.current = evStops;
|
||||
};
|
||||
const onEvSliderMove = (e: any) => {
|
||||
const g = evDragRef.current;
|
||||
if (!g || !onExposureChange) return;
|
||||
const dy = g.y0 - e.nativeEvent.locationY; // drag up = brighter
|
||||
const dy = g.y0 - pageYOf(e); // drag up = brighter
|
||||
const ev = clampEv(g.ev0 + dy / evPxPerStop);
|
||||
onExposureChange(Math.round(ev * 10) / 10);
|
||||
const rounded = Math.round(ev * 10) / 10;
|
||||
if (rounded === evLastSentRef.current) return; // same step: skip re-render
|
||||
evLastSentRef.current = rounded;
|
||||
onExposureChange(rounded);
|
||||
};
|
||||
const onEvSliderRelease = () => {
|
||||
evDragRef.current = null;
|
||||
};
|
||||
|
||||
// Instrumentation for device logs (user captures only logcat E level, so
|
||||
// console.log/warn never show up — these are error-level on purpose).
|
||||
const lastPreviewLog = useRef(0);
|
||||
useEffect(() => {
|
||||
const now = Date.now();
|
||||
if (now - lastPreviewLog.current < 400) return;
|
||||
lastPreviewLog.current = now;
|
||||
console.error(
|
||||
`[preview] matrix=(${colorMatrix[0].toFixed(3)},${colorMatrix[5].toFixed(3)},${colorMatrix[10].toFixed(3)}) tone=[${toneParams.join(',')}] ev=${evStops}`
|
||||
);
|
||||
}, [colorMatrix, toneParams, evStops]);
|
||||
// ---- Drag-anywhere-on-the-image adjustment --------------------------------
|
||||
// Priority: an open panel param row (imageAdjustTarget) > EV (AE locked).
|
||||
// A plain tap (no movement) always falls through to tap-to-focus.
|
||||
const TAP_SLOP = 14; // px of movement before a touch counts as a drag
|
||||
const imageAdjustTargetRef = useRef(imageAdjustTarget);
|
||||
imageAdjustTargetRef.current = imageAdjustTarget;
|
||||
type ImgDragSession = {
|
||||
y0: number;
|
||||
kind: 'param' | 'ev' | 'focus';
|
||||
v0: number;
|
||||
param: { min: number; max: number; step: number; onChange: (v: number) => void };
|
||||
moved: boolean;
|
||||
lastSent: number | null;
|
||||
};
|
||||
const imgDragRef = useRef<ImgDragSession | null>(null);
|
||||
const onImageDragGrant = (e: any) => {
|
||||
const t = imageAdjustTargetRef.current;
|
||||
let kind: ImgDragSession['kind'] = 'focus';
|
||||
let v0 = 0;
|
||||
let param: ImgDragSession['param'] = { min: 0, max: 0, step: 0, onChange: () => {} };
|
||||
if (t) {
|
||||
kind = 'param';
|
||||
v0 = t.value;
|
||||
param = { min: t.min, max: t.max, step: t.step, onChange: t.onChange };
|
||||
} else if (focusPoint && onExposureChange) {
|
||||
kind = 'ev';
|
||||
v0 = evStops;
|
||||
}
|
||||
imgDragRef.current = { y0: pageYOf(e), kind, v0, param, moved: false, lastSent: null };
|
||||
};
|
||||
const onImageDragMove = (e: any) => {
|
||||
const g = imgDragRef.current;
|
||||
if (!g || g.kind === 'focus') return;
|
||||
const dy = g.y0 - pageYOf(e); // up = positive / increase
|
||||
if (!g.moved) {
|
||||
if (Math.abs(dy) < TAP_SLOP) return;
|
||||
g.moved = true;
|
||||
}
|
||||
if (g.kind === 'ev') {
|
||||
if (!onExposureChange) return;
|
||||
const ev = clampEv(g.v0 + dy / evPxPerStop);
|
||||
const rounded = Math.round(ev * 10) / 10;
|
||||
if (rounded === g.lastSent) return;
|
||||
g.lastSent = rounded;
|
||||
evLastSentRef.current = rounded;
|
||||
onExposureChange(rounded);
|
||||
return;
|
||||
}
|
||||
const range = g.param.max - g.param.min;
|
||||
let v = g.v0 + (dy * range) / evTrackH; // same feel as the EV track
|
||||
v = Math.max(g.param.min, Math.min(g.param.max, v));
|
||||
if (g.param.step > 0) v = Math.round(v / g.param.step) * g.param.step;
|
||||
const rounded = Math.round(v * 1000) / 1000;
|
||||
if (rounded === g.lastSent) return;
|
||||
g.lastSent = rounded;
|
||||
g.param.onChange(rounded);
|
||||
};
|
||||
const onImageDragEnd = (e: any) => {
|
||||
const g = imgDragRef.current;
|
||||
imgDragRef.current = null;
|
||||
if (!g) return;
|
||||
if (!g.moved) {
|
||||
// Plain tap: lock focus at the tapped point (drag never re-locks).
|
||||
handleTapFocus(e.nativeEvent.locationX, e.nativeEvent.locationY);
|
||||
}
|
||||
};
|
||||
|
||||
// Camera frame renderer. Stable identity (empty-ish deps): SkiaCamera re-runs
|
||||
// its frame pipeline whenever onFrame changes, which during slider drags made
|
||||
// the preview flicker. All live values flow through the synchronizables.
|
||||
type SkiaCameraOnFrame = NonNullable<ComponentProps<typeof SkiaCamera>['onFrame']>;
|
||||
const toneFailLogged = useMemo(() => createSynchronizable<boolean>(false), []);
|
||||
// DEBUG-4x3 (remove after diagnosis): log actual stream geometry at runtime.
|
||||
const dbgCountSync = useMemo(() => createSynchronizable<number>(0), []);
|
||||
const dbgLastSync = useMemo(() => createSynchronizable<string>(''), []);
|
||||
// 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
|
||||
@@ -418,10 +532,23 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
|
||||
(frame, render) => {
|
||||
'worklet';
|
||||
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
|
||||
// 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();
|
||||
paint.setColorFilter(Skia.ColorFilter.MakeMatrix(colorMatrixSync.getBlocking()));
|
||||
const tone = toneSync.getBlocking();
|
||||
const cinema = cinemaSync.getBlocking();
|
||||
try {
|
||||
const cm = colorMatrixSync.getDirty();
|
||||
if (cm != null && cm.length === 20 && cm.every((v) => typeof v === 'number' && Number.isFinite(v))) {
|
||||
paint.setColorFilter(Skia.ColorFilter.MakeMatrix(cm));
|
||||
} else {
|
||||
console.warn('Invalid color matrix (len ' + (cm == null ? 'null' : cm.length) + ') — filter skipped');
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Color matrix build failed: ' + e);
|
||||
}
|
||||
const tone = toneSync.getDirty();
|
||||
const cinema = cinemaSync.getDirty();
|
||||
const hasTone = toneEffect != null && (tone[0] !== 0 || tone[1] !== 0 || tone[2] !== 0);
|
||||
const hasCinema = cinemaEffect != null && cinema[0] > 0;
|
||||
// Draws the full frame with the tone/cinema shader when active (drawRect,
|
||||
@@ -450,7 +577,7 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
|
||||
} catch (e) {
|
||||
if (!toneFailLogged.getBlocking()) {
|
||||
toneFailLogged.setBlocking(true);
|
||||
console.error('[camtone] shader failed, matrix-only: ' + e);
|
||||
console.error('Tone shader failed, matrix-only: ' + e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -463,21 +590,12 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
|
||||
// (under the renderToTexture rotation/mirror CTM) and cover-map the
|
||||
// sensor there. Equal-aspect bands (ratio 4:3 == native 3:4 sensor)
|
||||
// then show the ENTIRE sensor; narrower bands crop like the export.
|
||||
const feed = feedRectSync.getBlocking();
|
||||
const feed = feedRectSync.getDirty();
|
||||
const ftW = frameTexture.width();
|
||||
const ftH = frameTexture.height();
|
||||
const orient = frame.orientation;
|
||||
const dbgMir = frame.isMirrored === true;
|
||||
const dbgCount = dbgCountSync.getBlocking();
|
||||
dbgCountSync.setBlocking(dbgCount + 1);
|
||||
const bandActive = feed[0] > 0;
|
||||
if (!bandActive) {
|
||||
// DEBUG-4x3 (remove): stream ground truth, no-band branch
|
||||
const line = `ft=${ftW}x${ftH} o=${orient} mir=${dbgMir} band=0 feed=[${feed.map((f) => f.toFixed(3)).join(',')}]`;
|
||||
if (dbgCount % 60 === 0 || line !== dbgLastSync.getBlocking()) {
|
||||
dbgLastSync.setBlocking(line);
|
||||
console.error('[camdebug] ' + line);
|
||||
}
|
||||
drawFullFrame();
|
||||
return;
|
||||
}
|
||||
@@ -549,12 +667,6 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
|
||||
const dh = s * ftH;
|
||||
const dx = u0 + (rw - dw) / 2;
|
||||
const dy = v0 + (rh - dh) / 2;
|
||||
// DEBUG-4x3 (remove): full geometry log for the band branch
|
||||
const line = `ft=${ftW}x${ftH} o=${orient} mir=${dbgMir} band=1 feed=[${feed.map((f) => f.toFixed(3)).join(',')}] up=${ow}x${oh} ac=${ac.toFixed(4)} vis=${visX.toFixed(3)}x${visY.toFixed(3)} X0=${X0.toFixed(0)} Y0=${Y0.toFixed(0)} X1=${X1.toFixed(0)} Y1=${Y1.toFixed(0)} raw=${u0.toFixed(0)},${v0.toFixed(0)} ${rw.toFixed(0)}x${rh.toFixed(0)} s=${s.toFixed(4)} d=${dx.toFixed(0)},${dy.toFixed(0)}`;
|
||||
if (dbgCount % 60 === 0 || line !== dbgLastSync.getBlocking()) {
|
||||
dbgLastSync.setBlocking(line);
|
||||
console.error('[camdebug] ' + line);
|
||||
}
|
||||
// Fresh paint; its default color is opaque black (Skia default), so
|
||||
// the surface fill below needs no explicit color set.
|
||||
const blackPaint = Skia.Paint();
|
||||
@@ -644,19 +756,6 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
|
||||
: [0, 0, 0, 0, 0, 0]
|
||||
);
|
||||
}, [cameraPhotoRect, vw, vh, feedRectSync]);
|
||||
// DEBUG-4x3 (remove): correlate JS view/region state with the worklet log.
|
||||
useEffect(() => {
|
||||
console.error(
|
||||
'[camdebug-js] region=' + JSON.stringify(region) +
|
||||
' band=' + regionBand +
|
||||
' ratio=' + aspectRatio +
|
||||
' frame=' + selectedFrame +
|
||||
' view=' + vw + 'x' + vh +
|
||||
' feed=' + (cameraPhotoRect ? JSON.stringify([1, vw / vh, cameraPhotoRect.x / vw, cameraPhotoRect.y / vh, cameraPhotoRect.w / vw, cameraPhotoRect.h / vh]) : 'null') +
|
||||
' frameTarget=' + (frameTarget ? frameTarget.width + 'x' + frameTarget.height : 'null')
|
||||
);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [region, regionBand, aspectRatio, selectedFrame, cameraPhotoRect]);
|
||||
const onLibTouchStart = (e: any) => {
|
||||
const ts = e.nativeEvent.touches;
|
||||
if (ts.length >= 2) {
|
||||
@@ -981,16 +1080,21 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
|
||||
outputs={cameraOutputs}
|
||||
pixelFormat="yuv"
|
||||
targetResolution={frameTarget}
|
||||
onStarted={() => setCameraActive(true)}
|
||||
onStarted={handleCameraStarted}
|
||||
onStopped={handleCameraStopped}
|
||||
onFrame={handleFrame}
|
||||
onError={onSessionError}
|
||||
/>
|
||||
{/* Tap-to-focus layer: fullscreen responder (all visual overlays above
|
||||
are pointerEvents none). locationX/Y are relative to this view. */}
|
||||
{/* Gesture layer: tap = AE/AF lock; vertical drag on the image adjusts
|
||||
the open panel parameter (or EV while AE is locked). Sits below the
|
||||
quick-EV strip, which keeps its own narrower direct-drag handle. */}
|
||||
<View
|
||||
className="absolute inset-0"
|
||||
onStartShouldSetResponder={() => true}
|
||||
onResponderGrant={(e) => handleTapFocus(e.nativeEvent.locationX, e.nativeEvent.locationY)}
|
||||
onResponderGrant={onImageDragGrant}
|
||||
onResponderMove={onImageDragMove}
|
||||
onResponderRelease={onImageDragEnd}
|
||||
onResponderTerminate={onImageDragEnd}
|
||||
/>
|
||||
{focusPoint && (
|
||||
<View
|
||||
|
||||
@@ -5,6 +5,24 @@ export type FrameId = 'none' | 'polaroid' | 'classic-white' | 'cinematic' | 'wal
|
||||
// 2:3 photo (w/h = 2/3). "full" = current fullscreen behaviour (no ratio crop).
|
||||
// Wall Frame ignores the ratio (frame + export keep fullscreen as before).
|
||||
export type AspectRatio = 'full' | '4:3' | '3:2';
|
||||
// Full-frame metering behaviour. Evaluative = the camera's default whole-scene
|
||||
// metering (offset 0). Highlight-weighted protects bright areas by underexposing
|
||||
// (~ -0.7EV) — the camera lib exposes no real metering-mode API, so this is the
|
||||
// closest honest approximation via the existing EV path.
|
||||
export type MeterMode = 'evaluative' | 'highlight';
|
||||
// Camera shutter sound. OS policy may force the click on in some regions;
|
||||
// there is no native volume control (OFF/ON only).
|
||||
export type ShutterSound = 'on' | 'off';
|
||||
// One photo in the in-app session history (recent captures + library exports).
|
||||
// uri = app-cache JPEG; assetId (when savedToLibrary) = the MediaLibrary asset
|
||||
// so the trash button can remove the gallery copy too.
|
||||
export interface PhotoEntry {
|
||||
id: string;
|
||||
uri: string;
|
||||
recipeName: string;
|
||||
savedToLibrary: boolean;
|
||||
assetId?: string | null;
|
||||
}
|
||||
// w/h of the final upright photo for each non-full choice.
|
||||
export const ASPECT_RATIO_W_H: Record<Exclude<AspectRatio, 'full'>, number> = {
|
||||
'4:3': 3 / 4,
|
||||
@@ -16,12 +34,16 @@ export const ASPECT_RATIO_W_H: Record<Exclude<AspectRatio, 'full'>, number> = {
|
||||
// cool tint in shade, plus a seasonal atmospheric haze/fog veil.
|
||||
export type CinemaSeason = 'spring' | 'summer' | 'autumn' | 'winter';
|
||||
|
||||
export type BaseFilter = 'classic-neg' | 'classic-chrome' | 'provia' | 'velvia' | 'astia' | 'eterna' | 'monochrome' | 'none';
|
||||
|
||||
export interface ColorAdjustments {
|
||||
exposure: number; // -10 to +10 (mapped to matrix multiplier or offset)
|
||||
contrast: number; // -10 to +10
|
||||
saturation: number; // -10 to +10
|
||||
temperature: number; // 2500 to 10000 (Kelvin)
|
||||
tint: number; // -10 to +10
|
||||
wbRed?: number; // Fuji WB fine-tune, Red axis (-9..9) — extra red-channel gain
|
||||
wbBlue?: number; // Fuji WB fine-tune, Blue axis (-9..9) — extra blue-channel gain
|
||||
highlight: number; // -10 to +10 (- pulls highlights down / roll, + brightens)
|
||||
shadow: number; // -10 to +10 (+ lifts shadows, - deepens)
|
||||
denoise: number; // 0 to 10 (mapped to blur sigma)
|
||||
@@ -38,7 +60,7 @@ export interface Recipe {
|
||||
id: string;
|
||||
name: string;
|
||||
isCustom?: boolean;
|
||||
baseFilter: 'classic-neg' | 'provia' | 'velvia' | 'monochrome' | 'none';
|
||||
baseFilter: BaseFilter;
|
||||
adjustments: ColorAdjustments;
|
||||
frameId?: FrameId; // e.g. 'none', 'polaroid', 'classic-white', 'cinematic', 'wallframe'
|
||||
cinema?: CinemaSeason | null; // seasonal atmosphere grade (optional)
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
import { ColorAdjustments } from '../types';
|
||||
|
||||
export type BaseFilter = 'classic-neg' | 'provia' | 'velvia' | 'monochrome' | 'none';
|
||||
import { ColorAdjustments, BaseFilter } from '../types';
|
||||
|
||||
// Kelvin to RGB compensation for White Balance.
|
||||
// In photography, when WB is set HIGH (e.g. 10000K), the camera understands
|
||||
@@ -75,6 +73,31 @@ export function getSkiaColorMatrix(baseFilter: BaseFilter, adj: ColorAdjustments
|
||||
-0.05, -0.05, 1.25, 0, -0.02,
|
||||
0, 0, 0, 1, 0,
|
||||
];
|
||||
} else if (baseFilter === 'classic-chrome') {
|
||||
// Classic Chrome: muted/deep color with strong tone — desaturated reds and
|
||||
// greens, slight warm amber in highlights, shadows stay soft (approx).
|
||||
matrix = [
|
||||
1.12, -0.03, -0.06, 0, 0.04,
|
||||
-0.04, 1.0, -0.03, 0, 0.03,
|
||||
-0.05, -0.06, 0.86, 0, -0.02,
|
||||
0, 0, 0, 1, 0,
|
||||
];
|
||||
} else if (baseFilter === 'astia') {
|
||||
// Astia: soft, gentle, flattering — low contrast, pastel-leaning colors.
|
||||
matrix = [
|
||||
1.0, 0.02, 0.02, 0, 0.01,
|
||||
0.01, 1.0, 0.02, 0, 0.02,
|
||||
0.01, 0.01, 1.02, 0, 0.02,
|
||||
0, 0, 0, 1, 0,
|
||||
];
|
||||
} else if (baseFilter === 'eterna') {
|
||||
// Eterna: cinema — flat (low contrast), muted color, gentle highlight rolloff.
|
||||
matrix = [
|
||||
0.94, 0.02, 0.02, 0, 0.02,
|
||||
0.02, 0.96, 0.02, 0, 0.02,
|
||||
0.02, 0.02, 0.94, 0, 0.02,
|
||||
0, 0, 0, 1, 0,
|
||||
];
|
||||
} else if (baseFilter === 'monochrome') {
|
||||
// Acros Monochrome: ITU-R BT.709 luma — pure grayscale, zero color leakage
|
||||
const r = 0.2126;
|
||||
@@ -148,6 +171,22 @@ export function getSkiaColorMatrix(baseFilter: BaseFilter, adj: ColorAdjustments
|
||||
matrix = multiplyMatrices(wbMat, matrix);
|
||||
}
|
||||
|
||||
// 5b. Fuji WB fine-tune Red/Blue axis (the CREATE modal's R/B offsets). These
|
||||
// axes are independent channel gains on top of the Kelvin/tint preset: each
|
||||
// step nudges its channel ~1.1% toward/away from neutral, matching the
|
||||
// perceptible size of one Fuji fine-tune step (-9..+9).
|
||||
const wbRed = adj.wbRed ?? 0;
|
||||
const wbBlue = adj.wbBlue ?? 0;
|
||||
if (baseFilter !== 'monochrome' && (wbRed !== 0 || wbBlue !== 0)) {
|
||||
const shiftMat = [
|
||||
1 + (wbRed / 9) * 0.1, 0, 0, 0, 0,
|
||||
0, 1, 0, 0, 0,
|
||||
0, 0, 1 + (wbBlue / 9) * 0.1, 0, 0,
|
||||
0, 0, 0, 1, 0,
|
||||
];
|
||||
matrix = multiplyMatrices(shiftMat, matrix);
|
||||
}
|
||||
|
||||
// 6. Exposure & Contrast — applied to ALL base filters (monochrome included) so
|
||||
// both keep affecting mono shots. Contrast scales around mid-grey 0.5.
|
||||
if (exposure !== 0 || contrast !== 0) {
|
||||
|
||||
@@ -9,6 +9,8 @@ export const DEFAULT_ADJUSTMENTS: ColorAdjustments = {
|
||||
saturation: 0,
|
||||
temperature: 5500,
|
||||
tint: 0,
|
||||
wbRed: 0,
|
||||
wbBlue: 0,
|
||||
highlight: 0,
|
||||
shadow: 0,
|
||||
denoise: 0,
|
||||
@@ -21,6 +23,40 @@ export const DEFAULT_ADJUSTMENTS: ColorAdjustments = {
|
||||
exposureCompensation: 0,
|
||||
};
|
||||
|
||||
// Standard Fuji film simulations. These are the fixed "look" presets shown at
|
||||
// the front of the PRESETS chip row: tapping one applies the simulation via
|
||||
// its baseFilter colour matrix ONLY — every other knob resets to its neutral
|
||||
// default (0 / auto) so the sim is a clean starting point, never a baked
|
||||
// recipe. Any look beyond the matrix (grain, DR, CC, contrast…) is added by
|
||||
// the user afterwards.
|
||||
export interface FilmSim {
|
||||
id: string; // Recipe-like id used only while the sim is the current selection
|
||||
name: string;
|
||||
baseFilter: Recipe['baseFilter'];
|
||||
adjustments: ColorAdjustments;
|
||||
}
|
||||
|
||||
export const FILM_SIMS: FilmSim[] = [
|
||||
{ id: 'sim-provia', name: 'PROVIA', baseFilter: 'provia', adjustments: { ...DEFAULT_ADJUSTMENTS } },
|
||||
{ id: 'sim-velvia', name: 'VELVIA', baseFilter: 'velvia', adjustments: { ...DEFAULT_ADJUSTMENTS } },
|
||||
{ id: 'sim-classic-chrome', name: 'CLASSIC CHROME', baseFilter: 'classic-chrome', adjustments: { ...DEFAULT_ADJUSTMENTS } },
|
||||
{ id: 'sim-astia', name: 'ASTIA', baseFilter: 'astia', adjustments: { ...DEFAULT_ADJUSTMENTS } },
|
||||
{ id: 'sim-eterna', name: 'ETERNA', baseFilter: 'eterna', adjustments: { ...DEFAULT_ADJUSTMENTS } },
|
||||
{ id: 'sim-acros', name: 'ACROS', baseFilter: 'monochrome', adjustments: { ...DEFAULT_ADJUSTMENTS } },
|
||||
];
|
||||
|
||||
// Film sim → full Recipe (never persisted; sims are applied on the fly).
|
||||
export function filmSimToRecipe(sim: FilmSim): Recipe {
|
||||
return {
|
||||
id: sim.id,
|
||||
name: sim.name,
|
||||
baseFilter: sim.baseFilter,
|
||||
adjustments: { ...sim.adjustments },
|
||||
frameId: 'none',
|
||||
useGeotag: true,
|
||||
};
|
||||
}
|
||||
|
||||
export const DEFAULT_RECIPES: Recipe[] = [
|
||||
{
|
||||
id: 'classic-neg-default',
|
||||
|
||||
@@ -13,6 +13,9 @@ import { patchJpegDpi } from './jpegDpi';
|
||||
export interface ExportResult {
|
||||
uri: string;
|
||||
savedToLibrary: boolean;
|
||||
// MediaLibrary asset id when the photo was saved to the gallery — lets the
|
||||
// photo viewer's trash button remove the gallery copy too.
|
||||
assetId?: string | null;
|
||||
}
|
||||
|
||||
export interface ExportOptions {
|
||||
@@ -69,9 +72,7 @@ function screenSharpenImage(image: SkImage, amount = 0.5): SkImage {
|
||||
// Unique filename per export. A fixed path made every export share one URI, and
|
||||
// RN's <Image> cache (keyed by URI) then kept showing the previous photo in the
|
||||
// thumbnail after a new capture overwrote the file. A fresh URI per export busts
|
||||
// that cache. Previous export file is deleted after the new one is written and
|
||||
// gallery-saved, so at most one stale temp file can ever exist.
|
||||
let lastExportPath: string | null = null;
|
||||
// that cache.
|
||||
const nextExportFile = () => `${FileSystem.cacheDirectory}camrecipe_pro_export_${Date.now()}.jpg`;
|
||||
|
||||
export async function processAndExportPhoto(
|
||||
@@ -82,10 +83,6 @@ export async function processAndExportPhoto(
|
||||
gpsInfo: GPSInfo | null,
|
||||
options?: ExportOptions
|
||||
): Promise<ExportResult | null> {
|
||||
const t0 = Date.now();
|
||||
// Direct console.error templates (user log captures are E-level only). Steps
|
||||
// use full static prefixes — release minification dropped step literals when
|
||||
// they were passed through a wrapper, so no indirection here.
|
||||
try {
|
||||
// 1. Read source image file into Skia
|
||||
const skiaData = await Skia.Data.fromURI(sourceUri);
|
||||
@@ -99,13 +96,9 @@ export async function processAndExportPhoto(
|
||||
console.error('Failed to parse image from URI');
|
||||
return null;
|
||||
}
|
||||
console.error(`[export] decode ${Date.now() - t0}ms`);
|
||||
|
||||
let skImage = skImage0;
|
||||
let width = skImage.width();
|
||||
let height = skImage.height();
|
||||
console.error(`[export] BEGIN ${width}x${height} ev=${recipe.adjustments.exposureCompensation ?? 0} evFromCamera=${options?.evFromCamera ?? false}`);
|
||||
|
||||
// 1b. Aspect-ratio crop (composition): center-largest upright crop of the
|
||||
// requested ratio. Runs BEFORE any processing so frame/GPS/grain/sharpen
|
||||
// all apply to exactly the pixels the user framed. Wall Frame is exempt —
|
||||
@@ -144,7 +137,6 @@ export async function processAndExportPhoto(
|
||||
skImage = cropped;
|
||||
width = cropW;
|
||||
height = cropH;
|
||||
console.error(`[export] aspect-crop ${width}x${height}`);
|
||||
}
|
||||
|
||||
// 2. Create offscreen canvas (GPU-backed when available — see createSurface)
|
||||
@@ -181,7 +173,7 @@ export async function processAndExportPhoto(
|
||||
try {
|
||||
toneEffect = Skia.RuntimeEffect.Make(TONE_SKSL);
|
||||
} catch (e) {
|
||||
console.error('[export] TONE_SKSL compile failed - falling back to matrix-only: ' + e);
|
||||
console.error('TONE_SKSL compile failed - falling back to matrix-only: ' + e);
|
||||
}
|
||||
if (toneEffect) {
|
||||
const imageShader = skImage.makeShaderOptions(
|
||||
@@ -194,10 +186,10 @@ export async function processAndExportPhoto(
|
||||
if (toneShader) {
|
||||
paint.setShader(toneShader);
|
||||
} else {
|
||||
console.error('[export] tone shader build failed - falling back to matrix-only');
|
||||
console.error('Tone shader build failed - falling back to matrix-only');
|
||||
}
|
||||
} else {
|
||||
console.error('[export] TONE_SKSL compile failed - falling back to matrix-only');
|
||||
console.error('TONE_SKSL compile failed - falling back to matrix-only');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -222,7 +214,7 @@ export async function processAndExportPhoto(
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[export] CINEMA_SKSL failed - skipped: ' + e);
|
||||
console.error('CINEMA_SKSL failed - skipped: ' + e);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -462,7 +454,6 @@ export async function processAndExportPhoto(
|
||||
}
|
||||
|
||||
// 9. Snapshot & Encode to JPEG (base64 string, no SkData round-trip)
|
||||
console.error(`[export] composite ${Date.now() - t0}ms`);
|
||||
let resultImage = outputSurface.makeImageSnapshot();
|
||||
// Sharpening knob (0..10) overrides the legacy boolean sharpen flag:
|
||||
// legacy camera captures keep sharpen:true → 0.5 fallback when knob is 0.
|
||||
@@ -471,7 +462,6 @@ export async function processAndExportPhoto(
|
||||
if (sharpenAmount > 0) {
|
||||
resultImage = screenSharpenImage(resultImage, sharpenAmount);
|
||||
}
|
||||
console.error(`[export] sharpen ${Date.now() - t0}ms`);
|
||||
// Encode straight to raw JPEG bytes — no base64. The old path round-tripped
|
||||
// the whole 12MP frame through JS base64 decode/encode for the DPI patch and
|
||||
// then writeAsStringAsync, which alone took ~6s of the render.
|
||||
@@ -481,8 +471,6 @@ export async function processAndExportPhoto(
|
||||
console.error('Failed to encode image to JPEG');
|
||||
return null;
|
||||
}
|
||||
console.error(`[export] encode ${Date.now() - t0}ms`);
|
||||
|
||||
// 9b. Patch DPI metadata (JFIF density / EXIF resolution) when requested —
|
||||
// in-place Uint8Array work, no base64 round-trip.
|
||||
if (options?.dpi && options.dpi > 0) {
|
||||
@@ -499,32 +487,23 @@ export async function processAndExportPhoto(
|
||||
console.error('Failed to write export file: ' + e);
|
||||
return null;
|
||||
}
|
||||
console.error(`[export] write ${Date.now() - t0}ms`);
|
||||
console.error(`[export] total-done ${Date.now() - t0}ms`);
|
||||
|
||||
// 11. Request Media Library permission & Save to device gallery
|
||||
// 11. Request Media Library permission & Save to device gallery.
|
||||
// createAssetAsync (not saveToLibraryAsync) so the caller learns the
|
||||
// gallery asset id — the photo viewer's trash button needs it to remove
|
||||
// the saved copy. ponytail: old exports are NOT retired here anymore — the
|
||||
// in-app photo history still references their cache files for the swipe
|
||||
// viewer; the OS cache purge reclaims them eventually.
|
||||
const mediaPermission = await MediaLibrary.requestPermissionsAsync();
|
||||
if (mediaPermission.granted) {
|
||||
try {
|
||||
await MediaLibrary.saveToLibraryAsync(exportFile);
|
||||
// New file is safely in the gallery — retire the previous export temp.
|
||||
if (lastExportPath && lastExportPath !== exportFile) {
|
||||
try {
|
||||
await FileSystem.deleteAsync(lastExportPath, { idempotent: true });
|
||||
} catch (e) {
|
||||
console.warn('Cleanup of previous export temp failed:', e);
|
||||
}
|
||||
}
|
||||
lastExportPath = exportFile;
|
||||
return { uri: exportFile, savedToLibrary: true };
|
||||
const asset = await MediaLibrary.createAssetAsync(exportFile);
|
||||
return { uri: exportFile, savedToLibrary: true, assetId: asset.id };
|
||||
} catch (e) {
|
||||
console.warn('Save to gallery failed:', e);
|
||||
}
|
||||
} else {
|
||||
console.warn('Media Library permission denied. Image saved to temporary cache only.');
|
||||
}
|
||||
console.error(`[export] gallery-save ${Date.now() - t0}ms`);
|
||||
|
||||
return { uri: exportFile, savedToLibrary: false };
|
||||
} catch (error) {
|
||||
console.error('Error during photo processing and export:', error);
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
import { ColorAdjustments } from '../types';
|
||||
|
||||
// Static metadata for the continuous (slider) adjustments of the IQ/WB/FILTERS
|
||||
// tabs — shared between AdjustmentPanel (chips + slider rows) and App (which
|
||||
// needs the open row's bounds to drive drag-on-the-image adjustment). The get
|
||||
// /set accessors are stateless so one table serves every render.
|
||||
export interface ContinuousParamDef {
|
||||
key: string;
|
||||
label: string;
|
||||
min: number;
|
||||
max: number;
|
||||
step: number;
|
||||
defaultValue: number;
|
||||
display: (v: number) => string;
|
||||
get: (a: ColorAdjustments) => number;
|
||||
set: (v: number) => Partial<ColorAdjustments>;
|
||||
}
|
||||
|
||||
const sign = (v: number) => (v > 0 ? `+${v}` : String(v));
|
||||
const oneEv = (v: number) => `${v > 0 ? '+' : ''}${v.toFixed(1)} EV`;
|
||||
|
||||
export const PARAM_DEFS: {
|
||||
iq: ContinuousParamDef[];
|
||||
wb: ContinuousParamDef[];
|
||||
filters: ContinuousParamDef[];
|
||||
} = {
|
||||
iq: [
|
||||
{
|
||||
key: 'exposure',
|
||||
label: 'EXPOSURE',
|
||||
min: -10,
|
||||
max: 10,
|
||||
step: 1,
|
||||
defaultValue: 0,
|
||||
display: sign,
|
||||
get: (a) => a.exposure,
|
||||
set: (v) => ({ exposure: v }),
|
||||
},
|
||||
{
|
||||
key: 'ev',
|
||||
label: 'EV',
|
||||
min: -3,
|
||||
max: 3,
|
||||
step: 0.5,
|
||||
defaultValue: 0,
|
||||
display: oneEv,
|
||||
get: (a) => a.exposureCompensation ?? 0,
|
||||
set: (v) => ({ exposureCompensation: v }),
|
||||
},
|
||||
{
|
||||
key: 'contrast',
|
||||
label: 'CONTRAST',
|
||||
min: -10,
|
||||
max: 10,
|
||||
step: 1,
|
||||
defaultValue: 0,
|
||||
display: sign,
|
||||
get: (a) => a.contrast,
|
||||
set: (v) => ({ contrast: v }),
|
||||
},
|
||||
{
|
||||
key: 'color',
|
||||
label: 'COLOR',
|
||||
min: -10,
|
||||
max: 10,
|
||||
step: 1,
|
||||
defaultValue: 0,
|
||||
display: sign,
|
||||
get: (a) => a.saturation,
|
||||
set: (v) => ({ saturation: v }),
|
||||
},
|
||||
{
|
||||
key: 'highlight',
|
||||
label: 'HIGHLIGHT',
|
||||
min: -10,
|
||||
max: 10,
|
||||
step: 1,
|
||||
defaultValue: 0,
|
||||
display: sign,
|
||||
get: (a) => a.highlight ?? 0,
|
||||
set: (v) => ({ highlight: v }),
|
||||
},
|
||||
{
|
||||
key: 'shadow',
|
||||
label: 'SHADOW',
|
||||
min: -10,
|
||||
max: 10,
|
||||
step: 1,
|
||||
defaultValue: 0,
|
||||
display: sign,
|
||||
get: (a) => a.shadow ?? 0,
|
||||
set: (v) => ({ shadow: v }),
|
||||
},
|
||||
],
|
||||
wb: [
|
||||
{
|
||||
key: 'temperature',
|
||||
label: 'COLOR TEMP',
|
||||
min: 2500,
|
||||
max: 10000,
|
||||
step: 100,
|
||||
defaultValue: 5500,
|
||||
display: (v) => `${v}K`,
|
||||
get: (a) => a.temperature,
|
||||
set: (v) => ({ temperature: v }),
|
||||
},
|
||||
{
|
||||
key: 'tint',
|
||||
label: 'TINT',
|
||||
min: -10,
|
||||
max: 10,
|
||||
step: 1,
|
||||
defaultValue: 0,
|
||||
display: sign,
|
||||
get: (a) => a.tint ?? 0,
|
||||
set: (v) => ({ tint: v }),
|
||||
},
|
||||
],
|
||||
filters: [
|
||||
{
|
||||
key: 'denoise',
|
||||
label: 'NOISE REDUCTION',
|
||||
min: 0,
|
||||
max: 10,
|
||||
step: 1,
|
||||
defaultValue: 0,
|
||||
display: String,
|
||||
get: (a) => a.denoise,
|
||||
set: (v) => ({ denoise: v }),
|
||||
},
|
||||
{
|
||||
key: 'clarity',
|
||||
label: 'CLARITY',
|
||||
min: -10,
|
||||
max: 10,
|
||||
step: 1,
|
||||
defaultValue: 0,
|
||||
display: sign,
|
||||
get: (a) => a.clarity,
|
||||
set: (v) => ({ clarity: v }),
|
||||
},
|
||||
{
|
||||
key: 'sharpening',
|
||||
label: 'SHARPENING',
|
||||
min: 0,
|
||||
max: 10,
|
||||
step: 1,
|
||||
defaultValue: 0,
|
||||
display: String,
|
||||
get: (a) => a.sharpening ?? 0,
|
||||
set: (v) => ({ sharpening: v }),
|
||||
},
|
||||
{
|
||||
key: 'grain',
|
||||
label: 'MONOCHROME GRAIN',
|
||||
min: 0,
|
||||
max: 10,
|
||||
step: 1,
|
||||
defaultValue: 0,
|
||||
display: String,
|
||||
get: (a) => a.grain,
|
||||
set: (v) => ({ grain: v }),
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -52,16 +52,16 @@ async function resolveFromMediaLibrary(
|
||||
try {
|
||||
perm = await MediaLibrary.requestPermissionsAsync(false, ['photo']);
|
||||
} catch (e) {
|
||||
console.error('[gps] tier2 MediaLibrary permission request failed: ' + String(e).slice(0, 120));
|
||||
console.error('Media library permission request failed (photo GPS): ' + String(e).slice(0, 120));
|
||||
return { gps: null, permissionDenied: true };
|
||||
}
|
||||
if (!perm.granted) {
|
||||
console.error('[gps] tier2 MediaLibrary permission denied');
|
||||
console.error('Media library read permission denied (photo GPS)');
|
||||
return { gps: null, permissionDenied: true };
|
||||
}
|
||||
const id = pickerAssetId(asset);
|
||||
if (!id) {
|
||||
console.error('[gps] tier2 no assetId to query MediaStore');
|
||||
console.warn('Photo has no media-library asset id — GPS lookup skipped');
|
||||
return { gps: null, permissionDenied: false };
|
||||
}
|
||||
try {
|
||||
@@ -69,7 +69,6 @@ async function resolveFromMediaLibrary(
|
||||
if (info.location && Number.isFinite(info.location.latitude) && Number.isFinite(info.location.longitude)) {
|
||||
const { latitude, longitude } = info.location;
|
||||
const locality = useGeotag ? await localityFor(latitude, longitude) : undefined;
|
||||
console.error(`[gps] tier2 MediaStore location lat=${latitude.toFixed(5)} lon=${longitude.toFixed(5)}`);
|
||||
return {
|
||||
gps: {
|
||||
latitude,
|
||||
@@ -82,9 +81,9 @@ async function resolveFromMediaLibrary(
|
||||
permissionDenied: false,
|
||||
};
|
||||
}
|
||||
console.error('[gps] tier2 MediaStore asset has no location (photo may have no GPS, or ACCESS_MEDIA_LOCATION read failed silently)');
|
||||
console.warn('Media library photo has no location (no GPS, or ACCESS_MEDIA_LOCATION denied silently)');
|
||||
} catch (e) {
|
||||
console.error('[gps] tier2 getAssetInfoAsync failed: ' + String(e).slice(0, 160));
|
||||
console.error('Media library asset lookup failed (photo GPS): ' + String(e).slice(0, 160));
|
||||
}
|
||||
return { gps: null, permissionDenied: false };
|
||||
}
|
||||
@@ -102,12 +101,11 @@ export async function resolvePhotoGPS(
|
||||
if (exifCoord) {
|
||||
const timestamp = parseExifTimestamp(asset.exif) ?? Date.now();
|
||||
const locality = useGeotag ? await localityFor(exifCoord.latitude, exifCoord.longitude) : undefined;
|
||||
console.error(`[gps] tier1 EXIF lat=${exifCoord.latitude.toFixed(5)} lon=${exifCoord.longitude.toFixed(5)}`);
|
||||
return {
|
||||
gps: { ...exifCoord, locality, country: undefined, timestamp, source: 'exif' },
|
||||
};
|
||||
}
|
||||
console.error('[gps] tier1 no EXIF GPS in picker metadata');
|
||||
console.warn('No EXIF GPS in picker metadata');
|
||||
|
||||
// Tier 2 — MediaLibrary metadata (Android 10+ requires ACCESS_MEDIA_LOCATION)
|
||||
const assetId = pickerAssetId(asset);
|
||||
@@ -116,14 +114,13 @@ export async function resolvePhotoGPS(
|
||||
if (gps) return { gps };
|
||||
if (permissionDenied) return { gps: null, permissionDenied: true };
|
||||
} else {
|
||||
console.error('[gps] tier2 skipped — no asset id/uri');
|
||||
console.warn('Photo GPS tier-2 skipped — no asset id/uri');
|
||||
}
|
||||
|
||||
// Tier 3 — device fallback (photo has no location; stamp the device's current spot)
|
||||
if (allowDeviceFallback) {
|
||||
const deviceGps = await getCurrentGPS();
|
||||
if (deviceGps) {
|
||||
console.error(`[gps] tier3 DEVICE GPS fallback lat=${deviceGps.latitude.toFixed(5)} lon=${deviceGps.longitude.toFixed(5)}`);
|
||||
return { gps: { ...deviceGps, source: 'device' } };
|
||||
}
|
||||
}
|
||||
|
||||