Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 674fe9d1e2 | |||
| 200be77903 | |||
| aa6a74f229 | |||
| 96f412e269 | |||
| 0d276861e0 | |||
| 4c419f266c |
@@ -175,3 +175,238 @@
|
||||
{"_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
-1
@@ -2,7 +2,7 @@
|
||||
"devices": [
|
||||
{
|
||||
"installationId": "3ac39ffb-519e-4fcd-b686-c20d0a63c449",
|
||||
"lastUsed": 1784254612714
|
||||
"lastUsed": 1784299195276
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
+254
@@ -0,0 +1,254 @@
|
||||
|
||||
# Kế Hoạch Khắc Phục Lỗi Giao Diện & Bộ Lọc Đồ Họa CamRecipe Pro
|
||||
|
||||
Tài liệu này đề xuất phương án kỹ thuật chi tiết nhằm khắc phục triệt để các lỗi giao diện và bộ lọc màu trên phiên bản di động hiện tại, dựa theo giao diện thực tế trong tệp tin `Screenshot_2026-07-17-09-47-33-861_com.locphamtran.recipescamera.jpg`.
|
||||
|
||||
---
|
||||
|
||||
## 1. Chuyển đổi chức năng nút biểu tượng Con Mắt (Eye Button) khi ở chế độ Thư viện
|
||||
|
||||
### Vấn đề kỹ thuật
|
||||
|
||||
Như mô tả trong `Screenshot_2026-07-17-09-47-33-861_com.locphamtran.recipescamera.jpg`, khi ứng dụng đang ở chế độ chỉnh sửa ảnh từ thư viện (Library/Edit Mode - nút lưu màu cam nằm ở trung tâm), nút góc dưới bên phải có biểu tượng con mắt vẫn hiển thị. Theo logic tối ưu hóa trải nghiệm người dùng, nút này cần trở thành phím tắt nhanh để quay ngược lại chế độ chụp ảnh trực tiếp (Camera Mode).
|
||||
|
||||
### Giải pháp khắc phục
|
||||
|
||||
Chúng ta sẽ thay đổi động thuộc tính `onPress` và biểu tượng (Icon) của nút điều khiển góc dưới bên phải dựa vào trạng thái nguồn ảnh hiện tại `currentInputSource`:
|
||||
|
||||
* **Nếu `currentInputSource === 'camera'`:** Nút đóng vai trò là nút bật/tắt lưới Grid hoặc Xem trước nhanh (Preview/Overlay).
|
||||
|
||||
|
||||
* **Nếu `currentInputSource === 'library'`:** Nút chuyển đổi biểu tượng thành Camera (`aperture` hoặc `camera` từ `lucide-react-native`) và khi chạm vào sẽ kích hoạt hàm chuyển chế độ `switchInputSource('camera')`.
|
||||
|
||||
|
||||
|
||||
```javascript
|
||||
// Logic render nút chức năng góc dưới bên phải
|
||||
<TouchableOpacity
|
||||
onPress={currentInputSource === 'library' ? () => switchInputSource('camera') : handleToggleGrid}
|
||||
className="w-12 h-12 rounded-full bg-zinc-900/80 items-center justify-center"
|
||||
>
|
||||
{currentInputSource === 'library' ? (
|
||||
// Icon Camera thay thế cho icon con mắt khi đang ở chế độ ảnh thư viện
|
||||
<CameraIcon stroke="#a1a1aa" size={20} />
|
||||
) : (
|
||||
// Giữ nguyên icon con mắt khi đang ở chế độ camera native
|
||||
<EyeIcon stroke="#a1a1aa" size={20} />
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. Khắc phục lỗi Topbar bị chèn dưới thanh trạng thái (Status Bar) hệ thống
|
||||
|
||||
### Vấn đề kỹ thuật
|
||||
|
||||
Phần đầu của ứng dụng chứa tiêu đề "CAMRECIPE PRO" và các phím chế độ "CAMERA", "LIBRARY" đang bị đẩy lên kịch trần màn hình và bị chèn lấp bởi các biểu tượng thông báo hệ thống (sóng wifi, cột sóng nhà mạng, phần trăm pin) như trong ảnh `Screenshot_2026-07-17-09-47-33-861_com.locphamtran.recipescamera.jpg`.
|
||||
|
||||
### Giải pháp khắc phục
|
||||
|
||||
Hiện tượng này xảy ra do lớp bọc ngoài cùng (Main Container) chưa được xử lý vùng an toàn chuẩn xác trên cả hai hệ điều hành. Chúng ta sẽ sử dụng thư viện chuẩn `react-native-safe-area-context` để lấy chính xác chiều cao vùng khuyết (Notch/Dynamic Island) của từng thiết bị di động:
|
||||
|
||||
* **Cài đặt thư viện an toàn:** Sử dụng gói `react-native-safe-area-context`.
|
||||
|
||||
|
||||
* **Áp dụng hook `useSafeAreaInsets`:** Lấy giá trị viền đệm top thời gian thực của thiết bị.
|
||||
|
||||
|
||||
* **Cấu hình đệm động cho phần Header:**
|
||||
|
||||
```typescript
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
|
||||
export default function MainLayout() {
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
return (
|
||||
<View className="flex-1 bg-black">
|
||||
{/* Header Container được đệm an toàn tự động tránh Status Bar */}
|
||||
<View
|
||||
style={{ paddingTop: Math.max(insets.top, 12) }}
|
||||
className="px-5 pb-3 border-b border-zinc-900 bg-zinc-950 flex-row justify-between items-center"
|
||||
>
|
||||
<View className="flex-row items-center gap-2">
|
||||
<View className="w-2.5 h-2.5 rounded-full bg-amber-500 animate-pulse" />
|
||||
<Text className="text-xs font-bold text-zinc-400 tracking-widest uppercase">CAMRECIPE PRO</Text>
|
||||
</View>
|
||||
{/* Toggle buttons... */}
|
||||
</View>
|
||||
|
||||
{/* Phần Viewfinder và thân dưới ứng dụng */}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Khắc phục lỗi Live Viewfinder bị mờ nhòe (Blur Overlay) khi áp dụng Recipe
|
||||
|
||||
### Vấn đề kỹ thuật
|
||||
|
||||
Khi người dùng kích hoạt live preview của recipe, kính ngắm camera hiển thị một lớp mờ sương, khiến độ sắc nét bị giảm mạnh so với camera native của thiết bị. Lỗi này xuất phát từ việc chuỗi lọc hình ảnh `Skia.ImageFilter` hoặc Gaussian Blur bị cấu hình sai trị số mặc định (luôn áp dụng một mức mờ tối thiểu lớn hơn $0$ dù người dùng không giảm Clarity hay bật Denoise).
|
||||
|
||||
### Giải pháp khắc phục
|
||||
|
||||
Chúng ta cần cô lập hoàn toàn bộ lọc mờ và chỉ cho phép kích hoạt khi và chỉ khi các giá trị tương ứng (Clarity âm hoặc Denoise) được thiết lập một cách chủ động từ người dùng:
|
||||
|
||||
* **Kiểm tra điều kiện biên (Boundary Check):** Trước khi đưa ảnh qua bộ lọc Blur, thực hiện kiểm tra điều kiện. Nếu `clarity >= 0` và `grain_strength >= 0` (không ở trạng thái denoise), chúng ta sẽ bỏ qua hoàn toàn (bypass) bộ lọc nhòe và trả lại luồng ảnh gốc sắc nét nguyên bản từ phần cứng.
|
||||
|
||||
|
||||
* **Đồng bộ hóa luồng lọc trên GPU:**
|
||||
|
||||
```typescript
|
||||
// Logic tối ưu hóa bộ lọc Skia tránh mờ nhòe viewfinder
|
||||
const getActiveFilters = (clarity: number, grainStrength: number) => {
|
||||
const filters = [];
|
||||
|
||||
// Chỉ áp dụng Blur khi người dùng chủ động giảm Clarity xuống mức âm (để giả lập Black Mist)
|
||||
if (clarity < 0) {
|
||||
const blurSigma = Math.abs(clarity) * 0.45; // khống chế độ lệch chuẩn siêu mịn
|
||||
filters.push(Skia.ImageFilter.MakeBlur(blurSigma, blurSigma, TileMode.Clamp, null));
|
||||
}
|
||||
|
||||
// Chỉ áp dụng Denoise mượt mà khi mật độ hạt (grain_strength) được kéo về phía âm
|
||||
if (grainStrength < 0) {
|
||||
const denoiseSigma = Math.abs(grainStrength) * 0.35; // Làm mịn hạt nhưng giữ chi tiết rìa
|
||||
filters.push(Skia.ImageFilter.MakeBlur(denoiseSigma, denoiseSigma, TileMode.Clamp, null));
|
||||
}
|
||||
|
||||
return filters;
|
||||
};
|
||||
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Khắc phục lỗi chế độ Monochrome (ACROS MONO) vẫn hiển thị màu sắc trên Camera
|
||||
|
||||
### Vấn đề kỹ thuật
|
||||
|
||||
Mặc dù người dùng đã bấm chọn bộ màu trắng đen ACROS MONO (như đang hiển thị viền vàng hổ phách xung quanh nút chọn ở góc dưới), luồng hình ảnh truyền từ Camera trực tiếp lên kính ngắm vẫn có xu hướng bị rò rỉ màu sắc (không chuyển đổi xám hoàn toàn). Nguyên nhân là do ma trận chuyển đổi màu sắc (`ColorMatrix`) của ACROS sử dụng các hệ số kênh chưa chuẩn, hoặc chưa khóa (clamp) tuyệt đối dải bão hòa màu sắc (Saturation) về $0$.
|
||||
|
||||
### Giải pháp khắc phục
|
||||
|
||||
Để tạo ra chất ảnh trắng đen ACROS nghệ thuật chuẩn xác nhất, chúng phải triệt tiêu hoàn toàn thông tin màu bằng cách áp dụng chính xác các trọng số độ chói (Luma coefficients) theo tiêu chuẩn truyền hình quốc tế ITU-R BT.601 hoặc BT.709:
|
||||
|
||||
$$Y = 0.299 \cdot R + 0.587 \cdot G + 0.114 \cdot B$$
|
||||
|
||||
Chúng ta sẽ cấu hình ma trận màu Skia cho bộ lọc ACROS MONO như sau:
|
||||
|
||||
```typescript
|
||||
// Ma trận màu xám đơn sắc tuyệt đối (Monochrome ColorMatrix)
|
||||
const acrosMatrix = [
|
||||
0.299, 0.587, 0.114, 0, 0, // Kênh Đỏ chỉ lấy thông tin độ chói luma
|
||||
0.299, 0.587, 0.114, 0, 0, // Kênh Xanh lá tương tự
|
||||
0.299, 0.587, 0.114, 0, 0, // Kênh Xanh dương tương tự
|
||||
0, 0, 0, 1, 0 // Kênh Alpha giữ nguyên
|
||||
];
|
||||
|
||||
// Áp dụng bộ lọc phần cứng ma trận màu sắc triệt tiêu hoàn toàn màu gốc
|
||||
const colorFilter = Skia.ColorFilter.Matrix(
|
||||
currentFilmSim === 'acros' ? acrosMatrix : standardMatrix
|
||||
);
|
||||
|
||||
```
|
||||
|
||||
Bằng việc ép tất cả các hệ số đóng góp kênh màu về đúng tỷ lệ độ chói hồng ngoại của dải xám, camera native khi chuyển sang chế độ ACROS MONO sẽ không còn bất kỳ một vệt màu hay sắc độ ấm/lạnh nào bị rò rỉ lên Viewfinder.
|
||||
|
||||
---
|
||||
|
||||
## 5. Khắc phục lỗi GPS Geotag Watermark không đọc vị trí gốc của ảnh thư viện
|
||||
|
||||
### Vấn đề kỹ thuật
|
||||
|
||||
Khi người dùng tải lên một bức ảnh cũ từ thư viện (ví dụ: ảnh chụp tại TAM KỲ trong tệp `Screenshot_2026-07-17-09-47-33-861_com.locphamtran.recipescamera.jpg`), hệ thống Watermark lại tự động lấy vị trí định vị GPS thời gian thực của thiết bị điện thoại hiện tại. Điều này làm mất đi tính chính xác lịch sử của bức ảnh và phá hỏng cơ chế đóng dấu lưu niệm tự động (EXIF Geotagging).
|
||||
|
||||
### Giải pháp khắc phục
|
||||
|
||||
Chúng ta sẽ xây dựng một hàm phân tích đa tầng để ưu tiên trích xuất tọa độ GPS gốc lưu trong dữ liệu EXIF của chính tệp tin ảnh, và chỉ sử dụng GPS của điện thoại làm phương án dự phòng cuối cùng (fallback):
|
||||
|
||||
#### Tầng 1: Sử dụng API Native truy vấn siêu dữ liệu (Expo Media Library)
|
||||
|
||||
Khi lấy ảnh từ Thư viện qua `expo-image-picker` hoặc `expo-media-library`, chúng ta truy vấn thông tin chi tiết bằng mã định danh tài sản `assetId`:
|
||||
|
||||
```typescript
|
||||
import * as MediaLibrary from 'expo-media-library';
|
||||
|
||||
const getPhotoMetadata = async (assetId: string) => {
|
||||
try {
|
||||
const assetInfo = await MediaLibrary.getAssetInfoAsync(assetId);
|
||||
if (assetInfo.location) {
|
||||
// Trích xuất tọa độ địa lý gốc từ EXIF ảnh hệ thống
|
||||
return {
|
||||
latitude: assetInfo.location.latitude,
|
||||
longitude: assetInfo.location.longitude,
|
||||
source: 'EXIF'
|
||||
};
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn("Không thể đọc EXIF bằng MediaLibrary API:", err);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
```
|
||||
|
||||
#### Tầng 2: Bộ phân tích nhị phân EXIF dự phòng (Cho ảnh từ Cloud/Nguồn ngoài)
|
||||
|
||||
Nếu ảnh được truyền dưới dạng URI tạm thời không có `assetId`, chúng ta sử dụng gói phân tích nhị phân siêu nhẹ `exif-reader` để đọc trực tiếp byte đầu vào:
|
||||
|
||||
```typescript
|
||||
import ExifReader from 'exif-reader';
|
||||
import * as FileSystem from 'expo-file-stream'; // Hoặc expo-file-system
|
||||
|
||||
const parseRawExifGPS = async (fileUri: string) => {
|
||||
try {
|
||||
const base64Data = await FileSystem.readAsStringAsync(fileUri, { encoding: 'base64' });
|
||||
const arrayBuffer = Buffer.from(base64Data, 'base64');
|
||||
const tags = ExifReader(arrayBuffer);
|
||||
|
||||
if (tags.gps && tags.gps.GPSLatitude && tags.gps.GPSLongitude) {
|
||||
return {
|
||||
latitude: tags.gps.GPSLatitude,
|
||||
longitude: tags.gps.GPSLongitude,
|
||||
source: 'RawEXIF'
|
||||
};
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn("Không tìm thấy dữ liệu GPS EXIF trong tệp tin:", err);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
```
|
||||
|
||||
#### Tầng 3: Hàm hợp nhất Geotag thông minh
|
||||
|
||||
Kết hợp logic đa tầng tuần tự để ra quyết định định vị chính xác:
|
||||
|
||||
```typescript
|
||||
const resolvePhotoLocation = async (imageAsset: any) => {
|
||||
// Bước 1: Thử đọc GPS gốc từ album hệ thống (EXIF)
|
||||
let gpsData = await getPhotoMetadata(imageAsset.id);
|
||||
|
||||
// Bước 2: Thử phân tích nhị phân nếu bước 1 không ra kết quả
|
||||
if (!gpsData) {
|
||||
gpsData = await parseRawExifGPS(imageAsset.uri);
|
||||
}
|
||||
|
||||
// Bước 3: Nếu ảnh không chứa tọa độ gốc, dùng GPS của điện thoại làm dự phòng
|
||||
if (!gpsData) {
|
||||
const currentPhoneLocation = await getCurrentPhoneGPS();
|
||||
gpsData = { ...currentPhoneLocation, source: 'DeviceGPS' };
|
||||
}
|
||||
|
||||
// Bước 4: Gọi Reverse-Geocoding để in dấu địa danh nghệ thuật lên Canvas
|
||||
const locationName = await reverseGeocode(gpsData.latitude, gpsData.longitude);
|
||||
updateWatermarkUI(locationName, gpsData.latitude, gpsData.longitude);
|
||||
};
|
||||
|
||||
```
|
||||
@@ -0,0 +1,94 @@
|
||||
Kế Hoạch Sửa Lỗi Bộ Lọc Màu & Đồng Bộ Định Vị CamRecipe ProTài liệu này tập trung giải quyết triệt để các lỗi hiển thị hình ảnh trên kính ngắm (Viewfinder) di động thực tế dựa theo các tệp tin hình ảnh báo lỗi đính kèm từ người dùng.1. Khắc Phục Lỗi Nhiệt Độ Màu (White Balance Kelvin) Bị NgượcVấn đề kỹ thuật (Ảnh 3 & Ảnh 4)Hiện trạng thực tế:Như trong Screenshot_2026-07-17-10-25-59-810_com.locphamtran.recipescamera.jpg, khi kéo thanh trượt WB lên kịch khung 10000K, kính ngắm camera lại bị ám xanh dương lạnh ngắt.Ngược lại, trong Screenshot_2026-07-17-10-25-55-474_com.locphamtran.recipescamera.jpg, khi hạ WB xuống kịch khung 2500K, kính ngắm lại bị ám vàng/cam ấm rực rỡ.Nguyên nhân: Logic ánh xạ giữa giá trị Kelvin và ma trận bù trừ màu sắc (Color compensation matrix) bị nghịch đảo chéo góc. Trong nhiếp ảnh, khi cài đặt WB lên mức cao (ví dụ $10000\text{K}$), máy ảnh hiểu nguồn sáng môi trường đang rất lạnh nên phải bù trừ bằng sắc tông ấm (Amber/Orange). Khi cài đặt WB thấp ($2500\text{K}$), máy ảnh hiểu nguồn sáng rất ấm (Tungsten) nên phải bù trừ bằng dải tông lạnh (Blue) để đưa sắc trắng về trung tính.Giải pháp khắc phụcChúng ta sẽ viết lại thuật toán quy đổi Kelvin sang RGB bổ sung hệ số bù trừ ảnh nghịch đảo chính xác như sau:$$\text{Temp} = \frac{\text{Kelvin}}{100}$$Hệ số RGB bù trừ màu sắc thực tế sẽ được ánh xạ chính xác trên GPU:Nếu $\text{Kelvin} < 5500\text{K}$ (compensate for warm light): Giảm mạnh hệ số $R$, tăng hệ số $B$ để tạo tông màu xanh lạnh.Nếu $\text{Kelvin} \ge 5500\text{K}$ (compensate for cold light): Tăng mạnh hệ số $R$, giảm hệ số $B$ để tạo tông màu vàng ấm.// Sửa đổi hàm ánh xạ Kelvin trong file src/utils/kelvinToRGB.ts
|
||||
export function kelvinToRGBCompensation(kelvin: number) {
|
||||
const k = Math.min(10000, Math.max(2500, kelvin));
|
||||
let r = 1.0, g = 1.0, b = 1.0;
|
||||
|
||||
if (k < 5500) {
|
||||
// 2500K -> 5500K: Cần ám xanh dương để bù trừ ánh sáng ấm
|
||||
const factor = (5500 - k) / 3000; // dải 0 -> 1
|
||||
r = 1.0 - factor * 0.45;
|
||||
g = 1.0 - factor * 0.15;
|
||||
b = 1.0 + factor * 0.50; // Tăng cường sắc xanh dương
|
||||
} else {
|
||||
// 5500K -> 10000K: Cần ám vàng/đỏ để bù trừ ánh sáng lạnh
|
||||
const factor = (k - 5500) / 4500; // dải 0 -> 1
|
||||
r = 1.0 + factor * 0.55; // Tăng cường sắc đỏ ấm
|
||||
g = 1.0 + factor * 0.20;
|
||||
b = 1.0 - factor * 0.40;
|
||||
}
|
||||
|
||||
return { r, g, b };
|
||||
}
|
||||
2. Khắc Phục Lỗi Rò Rỉ Màu Trên Chế Độ Monochrome (ACROS MONO)Vấn đề kỹ thuật (Ảnh 1)Hiện trạng thực tế: Như hiển thị trong Screenshot_2026-07-17-10-23-53-813_com.locphamtran.recipescamera.jpg, khi nhấn chọn bộ lọc đơn sắc ACROS MONO, dải đèn LED RGB trên bàn phím máy tính vẫn rực rỡ sắc đỏ, xanh, hồng tím thay vì hiển thị dải xám đen mộc mạc.Nguyên nhân: Logic áp màu phim bỏ qua bộ lọc trắng đen của Skia do điều kiện rẽ nhánh ma trận màu bị lỗi hoặc hệ số ma trận màu trắng đen chưa triệt tiêu hoàn toàn dải bão hòa màu sắc (Saturation) trên luồng camera thời gian thực.Giải pháp khắc phụcÉp các hệ số ma trận màu sắc của ACROS MONO về đúng tiêu chuẩn độ chói quốc tế ITU-R BT.709 nhằm triệt tiêu hoàn toàn thông tin màu sắc:$$Y = 0.2126 \cdot R + 0.7152 \cdot G + 0.0722 \cdot B$$// Khóa chặt ma trận lọc màu trắng đen tuyệt đối của Skia
|
||||
const acrosMatrix = [
|
||||
0.2126, 0.7152, 0.0722, 0, 0, // Kênh Đỏ chỉ lấy thông tin độ chói xám
|
||||
0.2126, 0.7152, 0.0722, 0, 0, // Kênh Xanh lá tương tự
|
||||
0.2126, 0.7152, 0.0722, 0, 0, // Kênh Xanh dương tương tự
|
||||
0, 0, 0, 1, 0 // Giữ nguyên kênh Alpha
|
||||
];
|
||||
|
||||
// Áp dụng bộ lọc ma trận sắc độ xám đơn sắc chuẩn nhiếp ảnh
|
||||
const activeColorFilter = currentRecipe.film_sim === 'acros'
|
||||
? Skia.ColorFilter.Matrix(acrosMatrix)
|
||||
: Skia.ColorFilter.Matrix(standardColorMatrix);
|
||||
3. Loại Bỏ Lớp Mờ Sương (Fog Overlay) Trên Viewfinder Khi Thay Đổi WBVấn đề kỹ thuật (Ảnh 2)Hiện trạng thực tế: Như trong Screenshot_2026-07-17-10-25-47-625_com.locphamtran.recipescamera.jpg, khi chỉnh nhiệt độ màu về đúng trị số 6600K, lớp mờ sương (blur) bỗng nhiên biến mất kỳ lạ giúp ảnh sắc nét hẳn lên.Nguyên nhân: Lỗi phân chia dải biến số (division/overflow error) trong hàm shader tự động làm mờ ảnh khi xử lý nhiệt độ màu. Bộ lọc Gaussian Blur (feGaussianBlur) bị liên kết sai lệch với biến nhiệt độ màu wb, khiến cho ở các dải nhiệt độ khác ngoài 6600K, bán kính mờ (stdDeviation) bị tính toán sai vượt ngưỡng $0$ mặc dù độ sắc nét hoặc độ mịn màng không thay đổi.Giải pháp khắc phụcCô lập hoàn toàn giá trị mờ sương khỏi bộ điều phối WB. Chỉ kích hoạt hiệu ứng mờ sương khi và chỉ khi người dùng chủ động tinh chỉnh dải thanh trượt Clarity < 0 hoặc Denoise < 0:// Tách biệt hoàn toàn tính toán bán kính mờ sương khỏi White Balance
|
||||
const getBlurSigma = (clarity: number, grainStrength: number) => {
|
||||
'worklet';
|
||||
let blurSigma = 0;
|
||||
|
||||
// Chỉ áp dụng hiệu ứng Bloom/Mist mềm mại khi Clarity ở mức âm
|
||||
if (clarity < 0) {
|
||||
blurSigma = Math.abs(clarity) * 0.45; // Khống chế bán kính mờ mịn màng
|
||||
}
|
||||
|
||||
// Chỉ áp dụng khử nhiễu (Denoise) làm mượt hạt khi Grain ở mức âm
|
||||
if (grainStrength < 0) {
|
||||
const denoiseSigma = Math.abs(grainStrength) * 0.35;
|
||||
blurSigma = Math.max(blurSigma, denoiseSigma);
|
||||
}
|
||||
|
||||
return blurSigma; // Trả về 0 nếu các thông số này không ở mức âm
|
||||
};
|
||||
4. Đồng Bộ Hóa Recipes Thời Gian Thực Trên Native Camera PreviewVấn đề kỹ thuậtHiện trạng thực tế: Các thông số phơi sáng trong tab EXPOSURE hay dải lọc màu trong tab WB & CHROME thay đổi trên thanh trượt nhưng kính ngắm camera trực tiếp vẫn trơ trơ không thay đổi màu sắc.Nguyên nhân: Biến trạng thái tùy chỉnh của React Native (adjustments) khi thay đổi không kích hoạt việc vẽ lại (re-render) trên luồng xử lý frame của camera (Vision Camera Frame Processor hoặc Skia Canvas), do thiếu các biến liên kết phụ thuộc (dependency array) hoặc chưa đưa giá trị thông số màu về dạng Shared Values của thư viện react-native-reanimated.Giải pháp khắc phụcSử dụng hook useSharedValue để truyền giá trị tức thời của thanh sliders sang luồng xử lý đồ họa GPU của Skia nhằm cập nhật kính ngắm ở tốc độ 60 FPS:import { useSharedValue, useDerivedValue } from 'react-native-reanimated';
|
||||
|
||||
export function ViewfinderCamera() {
|
||||
// Đồng bộ hóa các dải thông số màu dưới dạng Shared Values của GPU
|
||||
const exposureVal = useSharedValue(0.7);
|
||||
const kelvinVal = useSharedValue(5800);
|
||||
const colorChromeVal = useSharedValue('strong');
|
||||
|
||||
// Đăng ký lắng nghe sự thay đổi của sliders để cập nhật tức thì ma trận màu Skia
|
||||
const skiaColorFilter = useDerivedValue(() => {
|
||||
const matrix = calculateDynamicMatrix(exposureVal.value, kelvinVal.value, colorChromeVal.value);
|
||||
return Skia.ColorFilter.Matrix(matrix);
|
||||
}, [exposureVal, kelvinVal, colorChromeVal]);
|
||||
|
||||
return (
|
||||
<Canvas style={{ flex: 1 }}>
|
||||
<CameraPreviewTexture />
|
||||
<ColorMatrix matrix={skiaColorFilter} />
|
||||
</Canvas>
|
||||
);
|
||||
}
|
||||
5. Khắc Phục Lỗi Đọc GPS Định Vị Gốc Của Ảnh Thư ViệnVấn đề kỹ thuậtHiện trạng thực tế: Khi tải ảnh lên từ thư viện, Geotag watermark ở góc dưới bên trái vẫn hiển thị tọa độ GPS của vị trí điện thoại hiện tại, bỏ qua lịch sử vị trí chụp gốc lưu trong EXIF của tệp tin.Nguyên nhân: API chọn ảnh (expo-image-picker) theo mặc định chỉ trả về một đường dẫn URI tạm thời bị lược bỏ hoàn toàn các siêu dữ liệu EXIF nhạy cảm vì lý do bảo mật quyền riêng tư của hệ điều hành.Giải pháp khắc phụcSử dụng gói truy vấn tài sản chuyên sâu expo-media-library để yêu cầu hệ thống cung cấp siêu dữ liệu chi tiết của bức ảnh gốc thông qua mã định danh tài sản (assetId):import * as MediaLibrary from 'expo-media-library';
|
||||
|
||||
export const getOriginalPhotoLocation = async (assetId: string) => {
|
||||
try {
|
||||
// Yêu cầu quyền truy cập thư viện đầy đủ từ hệ điều hành
|
||||
const { status } = await MediaLibrary.requestPermissionsAsync();
|
||||
if (status === 'granted') {
|
||||
// Truy vấn thông tin chi tiết của ảnh gốc bao gồm tọa độ GPS chụp lịch sử
|
||||
const assetInfo = await MediaLibrary.getAssetInfoAsync(assetId);
|
||||
if (assetInfo.location) {
|
||||
return {
|
||||
latitude: assetInfo.location.latitude,
|
||||
longitude: assetInfo.location.longitude,
|
||||
source: 'EXIF_ORIGINAL'
|
||||
};
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn("Không thể trích xuất định vị EXIF gốc từ ảnh thư viện:", error);
|
||||
}
|
||||
return null; // Trả về null để chuyển sang định vị hiện tại làm dự phòng (fallback)
|
||||
};
|
||||
@@ -1,8 +1,11 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { SafeAreaView, View, StatusBar, Alert, ActivityIndicator, Text } from 'react-native';
|
||||
import { useCameraPermissions } from 'expo-camera';
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { View, StatusBar, Alert, ActivityIndicator, Text, AppState } from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { useCameraPermission, type Camera } from 'react-native-vision-camera';
|
||||
import * as ImagePicker from 'expo-image-picker';
|
||||
import * as FileSystem from 'expo-file-system/legacy';
|
||||
import * as Haptics from 'expo-haptics';
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
|
||||
import Header from './src/components/Header';
|
||||
import Viewfinder from './src/components/Viewfinder';
|
||||
@@ -14,11 +17,14 @@ import PreviewModal from './src/components/PreviewModal';
|
||||
import { Recipe, ColorAdjustments, FrameId, GPSInfo } from './src/types';
|
||||
import { getAllRecipes, saveCustomRecipe, deleteCustomRecipe } from './src/utils/storageUtils';
|
||||
import { getCurrentGPS, requestLocationPermissions } from './src/utils/locationUtils';
|
||||
import { resolvePhotoGPS } from './src/utils/photoGps';
|
||||
import { processAndExportPhoto } from './src/utils/exportEngine';
|
||||
|
||||
// @ts-ignore
|
||||
import './global.css';
|
||||
|
||||
const SESSION_KEY = 'saved_session';
|
||||
|
||||
export default function App() {
|
||||
const [mode, setMode] = useState<'camera' | 'library'>('camera');
|
||||
const [recipes, setRecipes] = useState<Recipe[]>([]);
|
||||
@@ -46,21 +52,89 @@ export default function App() {
|
||||
|
||||
const [libraryImageUri, setLibraryImageUri] = useState<string | null>(null);
|
||||
const [lastPhotoUri, setLastPhotoUri] = useState<string | null>(null);
|
||||
const [lastSavedToLibrary, setLastSavedToLibrary] = useState(false);
|
||||
const [previewVisible, setPreviewVisible] = useState(false);
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
|
||||
// Hardware Permissions
|
||||
const [cameraPermission, requestCameraPermission] = useCameraPermissions();
|
||||
// Hardware Permissions (vision-camera)
|
||||
const { hasPermission: cameraPermissionGranted, requestPermission: requestCameraPermission } = useCameraPermission();
|
||||
const cameraRef = useRef<Camera>(null);
|
||||
|
||||
// vision-camera may return a raw absolute path on Android; surface a file:// URI.
|
||||
const toUri = (path: string) => (/^(file|content|ph):\/\//.test(path) ? path : `file://${path}`);
|
||||
|
||||
useEffect(() => {
|
||||
loadRecipes();
|
||||
loadGPS();
|
||||
// B5: run a single sequenced init — restore the session first, then fall
|
||||
// back to the first recipe only when no recipe was restored.
|
||||
initSession();
|
||||
// Do NOT auto-load device GPS here — it would override photo GPS on library mode
|
||||
}, []);
|
||||
|
||||
const loadRecipes = async () => {
|
||||
// Save/restore session on app lifecycle changes
|
||||
useEffect(() => {
|
||||
const subscription = AppState.addEventListener('change', async (nextAppState) => {
|
||||
if (nextAppState === 'background' || nextAppState === 'inactive') {
|
||||
const session = {
|
||||
mode,
|
||||
libraryImageUri,
|
||||
adjustments,
|
||||
selectedFrame,
|
||||
useGeotag,
|
||||
selectedRecipeId: selectedRecipe?.id || null,
|
||||
gpsInfo,
|
||||
};
|
||||
try {
|
||||
await AsyncStorage.setItem(SESSION_KEY, JSON.stringify(session));
|
||||
} catch (e) {
|
||||
console.warn('Failed to save session:', e);
|
||||
}
|
||||
}
|
||||
});
|
||||
return () => subscription.remove();
|
||||
}, [mode, libraryImageUri, adjustments, selectedFrame, useGeotag, selectedRecipe, gpsInfo]);
|
||||
|
||||
const initSession = async () => {
|
||||
const list = await getAllRecipes();
|
||||
setRecipes(list);
|
||||
if (list.length > 0) {
|
||||
|
||||
let session: any = null;
|
||||
try {
|
||||
const raw = await AsyncStorage.getItem(SESSION_KEY);
|
||||
if (raw) session = JSON.parse(raw);
|
||||
} catch (e) {
|
||||
console.warn('Failed to read session:', e);
|
||||
}
|
||||
|
||||
let restoredRecipe: Recipe | null = null;
|
||||
if (session) {
|
||||
// B12: the restored library image may no longer exist on disk
|
||||
let imageUri: string | null = session.libraryImageUri || null;
|
||||
if (imageUri) {
|
||||
try {
|
||||
const info = await FileSystem.getInfoAsync(imageUri);
|
||||
if (!info.exists) imageUri = null;
|
||||
} catch {
|
||||
imageUri = null;
|
||||
}
|
||||
}
|
||||
if (imageUri) {
|
||||
setLibraryImageUri(imageUri);
|
||||
setMode('library');
|
||||
} else {
|
||||
setMode(session.mode || 'camera');
|
||||
}
|
||||
if (session.adjustments) setAdjustments(session.adjustments);
|
||||
if (session.selectedFrame) setSelectedFrame(session.selectedFrame);
|
||||
if (session.useGeotag !== undefined) setUseGeotag(session.useGeotag);
|
||||
if (session.gpsInfo) setGpsInfo(session.gpsInfo);
|
||||
if (session.selectedRecipeId) {
|
||||
restoredRecipe = list.find((r: Recipe) => r.id === session.selectedRecipeId) || null;
|
||||
}
|
||||
}
|
||||
|
||||
if (restoredRecipe) {
|
||||
applyRecipe(restoredRecipe);
|
||||
} else if (list.length > 0) {
|
||||
applyRecipe(list[0]);
|
||||
}
|
||||
};
|
||||
@@ -68,7 +142,7 @@ export default function App() {
|
||||
const loadGPS = async () => {
|
||||
const info = await getCurrentGPS();
|
||||
if (info) {
|
||||
setGpsInfo(info);
|
||||
setGpsInfo({ ...info, source: 'device' });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -80,16 +154,15 @@ export default function App() {
|
||||
};
|
||||
|
||||
const handleUpdateAdjustments = (updates: Partial<ColorAdjustments>) => {
|
||||
setAdjustments((prev) => {
|
||||
const next = { ...prev, ...updates };
|
||||
if (selectedRecipe) {
|
||||
setSelectedRecipe({
|
||||
...selectedRecipe,
|
||||
adjustments: next,
|
||||
});
|
||||
}
|
||||
return next;
|
||||
});
|
||||
// B8: never call a setState inside another setState's updater.
|
||||
const next = { ...adjustments, ...updates };
|
||||
setAdjustments(next);
|
||||
if (selectedRecipe) {
|
||||
setSelectedRecipe({
|
||||
...selectedRecipe,
|
||||
adjustments: next,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdateFrame = (frameId: FrameId) => {
|
||||
@@ -146,62 +219,142 @@ export default function App() {
|
||||
};
|
||||
|
||||
const handlePickImage = async () => {
|
||||
if (isProcessing) return;
|
||||
const result = await ImagePicker.launchImageLibraryAsync({
|
||||
mediaTypes: ImagePicker.MediaTypeOptions.Images,
|
||||
allowsEditing: false,
|
||||
quality: 1,
|
||||
exif: true,
|
||||
});
|
||||
|
||||
if (!result.canceled && result.assets && result.assets.length > 0) {
|
||||
setLibraryImageUri(result.assets[0].uri);
|
||||
const asset = result.assets[0];
|
||||
setLibraryImageUri(asset.uri);
|
||||
setMode('library');
|
||||
// Clear old GPS — will be replaced if the photo (or device) has location data
|
||||
setGpsInfo(null);
|
||||
|
||||
// Multi-tier GPS resolution: EXIF → MediaLibrary → device fallback
|
||||
const { gps, permissionDenied } = await resolvePhotoGPS(asset, {
|
||||
useGeotag,
|
||||
allowDeviceFallback: useGeotag,
|
||||
});
|
||||
setGpsInfo(gps);
|
||||
if (permissionDenied) {
|
||||
console.warn('Media library read permission denied — photo GPS unavailable.');
|
||||
}
|
||||
|
||||
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
|
||||
}
|
||||
};
|
||||
|
||||
const activeRecipe: Recipe =
|
||||
selectedRecipe || {
|
||||
id: 'temp',
|
||||
name: 'Custom',
|
||||
baseFilter: 'none',
|
||||
adjustments,
|
||||
frameId: selectedFrame,
|
||||
useGeotag,
|
||||
};
|
||||
|
||||
const handleCapture = async () => {
|
||||
if (isProcessing) return;
|
||||
|
||||
// Camera mode (Phase 4, option A): real capture through vision-camera.
|
||||
if (mode === 'camera') {
|
||||
// For simulator & simplicity, if camera view isn't fully mocked, we use a placeholder or trigger camera picture.
|
||||
// In this setup, we simulate taking a photo or request library image if running in simulator.
|
||||
// Let's create a beautiful simulation that pulls a mock asset or lets you select one if in simulator.
|
||||
Alert.alert(
|
||||
'Capture Mode',
|
||||
'Camera capture requires native device hardware. For testing on emulators, please use the LIBRARY mode to load a photo and apply filters.',
|
||||
[{ text: 'OK' }]
|
||||
);
|
||||
} else {
|
||||
// Library mode capture/export: processes selected image with recipe, overlays, text
|
||||
if (!libraryImageUri || !selectedRecipe) {
|
||||
Alert.alert('Error', 'Please select an image from the library first.');
|
||||
if (!cameraPermissionGranted) {
|
||||
Alert.alert('Permission Needed', 'Grant camera permission to take photos.');
|
||||
return;
|
||||
}
|
||||
if (!cameraRef.current) {
|
||||
Alert.alert('Camera Not Ready', 'Please wait for the camera to start, then try again.');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsProcessing(true);
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
|
||||
try {
|
||||
// A photo taken now is at the device's current spot — resolve device GPS
|
||||
// fresh instead of reusing GPS left over from a previously loaded photo.
|
||||
let captureGps = gpsInfo && gpsInfo.source === 'device' ? gpsInfo : null;
|
||||
if (useGeotag && !captureGps) {
|
||||
const deviceGps = await getCurrentGPS();
|
||||
if (deviceGps) {
|
||||
captureGps = { ...deviceGps, source: 'device' as const };
|
||||
setGpsInfo(captureGps);
|
||||
}
|
||||
}
|
||||
|
||||
const exportedUri = await processAndExportPhoto(
|
||||
libraryImageUri,
|
||||
{
|
||||
...selectedRecipe,
|
||||
adjustments,
|
||||
frameId: selectedFrame,
|
||||
const photo = await cameraRef.current.takePhoto({});
|
||||
const sourceUri = toUri(photo.path);
|
||||
|
||||
const result = await processAndExportPhoto(
|
||||
sourceUri,
|
||||
{ ...activeRecipe, adjustments, frameId: selectedFrame, useGeotag },
|
||||
selectedFrame,
|
||||
useGeotag,
|
||||
},
|
||||
selectedFrame,
|
||||
useGeotag,
|
||||
gpsInfo
|
||||
);
|
||||
captureGps,
|
||||
{ dpi: 300, sharpen: true } // 300dpi header + full-res screen sharpen
|
||||
);
|
||||
|
||||
setIsProcessing(false);
|
||||
|
||||
if (exportedUri) {
|
||||
setLastPhotoUri(exportedUri);
|
||||
setPreviewVisible(true);
|
||||
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
|
||||
} else {
|
||||
Alert.alert('Failed', 'Error occurred while processing image.');
|
||||
if (result) {
|
||||
setLastPhotoUri(result.uri);
|
||||
setLastSavedToLibrary(result.savedToLibrary);
|
||||
setPreviewVisible(true);
|
||||
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
|
||||
} else {
|
||||
Alert.alert('Failed', 'Error occurred while processing the captured photo.');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Camera capture failed:', error);
|
||||
Alert.alert('Capture Failed', 'Could not take the photo. See logs for details.');
|
||||
} finally {
|
||||
setIsProcessing(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Library mode capture/export: processes selected image with recipe, overlays, text
|
||||
if (!libraryImageUri || !selectedRecipe) {
|
||||
Alert.alert('Error', 'Please select an image from the library first.');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsProcessing(true);
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
|
||||
|
||||
const result = await processAndExportPhoto(
|
||||
libraryImageUri,
|
||||
{
|
||||
...selectedRecipe,
|
||||
adjustments,
|
||||
frameId: selectedFrame,
|
||||
useGeotag,
|
||||
},
|
||||
selectedFrame,
|
||||
useGeotag,
|
||||
gpsInfo
|
||||
);
|
||||
|
||||
setIsProcessing(false);
|
||||
|
||||
if (result) {
|
||||
setLastPhotoUri(result.uri);
|
||||
setLastSavedToLibrary(result.savedToLibrary);
|
||||
setPreviewVisible(true);
|
||||
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
|
||||
} else {
|
||||
Alert.alert('Failed', 'Error occurred while processing image.');
|
||||
}
|
||||
};
|
||||
|
||||
// Switching back to camera: drop GPS that belongs to a library photo, so the
|
||||
// live view (and any capture) never stamps a stale location.
|
||||
const handleSetMode = (next: 'camera' | 'library') => {
|
||||
if (next === 'camera' && gpsInfo && gpsInfo.source !== 'device') {
|
||||
setGpsInfo(null);
|
||||
}
|
||||
setMode(next);
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -209,7 +362,7 @@ export default function App() {
|
||||
<StatusBar barStyle="light-content" backgroundColor="#08080a" />
|
||||
|
||||
{/* Top Header */}
|
||||
<Header mode={mode} setMode={setMode} title="CamRecipe Pro" />
|
||||
<Header mode={mode} setMode={handleSetMode} title="CamRecipe Pro" />
|
||||
|
||||
{/* Main Viewfinder Frame */}
|
||||
<View className="flex-1 justify-center items-center px-4 py-2 bg-carbon">
|
||||
@@ -229,8 +382,9 @@ export default function App() {
|
||||
useGeotag={useGeotag}
|
||||
gpsInfo={gpsInfo}
|
||||
libraryImageUri={libraryImageUri}
|
||||
cameraPermissionGranted={!!cameraPermission?.granted}
|
||||
cameraPermissionGranted={cameraPermissionGranted}
|
||||
onRequestCameraPermission={requestCameraPermission}
|
||||
cameraRef={cameraRef}
|
||||
/>
|
||||
</View>
|
||||
|
||||
@@ -261,7 +415,8 @@ export default function App() {
|
||||
onCapture={handleCapture}
|
||||
onPickImage={handlePickImage}
|
||||
onOpenPreview={() => setPreviewVisible(true)}
|
||||
isLibraryMode={mode === 'library' && !libraryImageUri}
|
||||
isLibraryMode={mode === 'library'}
|
||||
onSwitchToCamera={() => handleSetMode('camera')}
|
||||
/>
|
||||
|
||||
{/* Preview Modal */}
|
||||
@@ -270,6 +425,7 @@ export default function App() {
|
||||
onClose={() => setPreviewVisible(false)}
|
||||
photoUri={lastPhotoUri}
|
||||
recipeName={selectedRecipe?.name || 'Custom'}
|
||||
savedToLibrary={lastSavedToLibrary}
|
||||
/>
|
||||
|
||||
{/* Global Processing Loader Overlay */}
|
||||
|
||||
+144
@@ -0,0 +1,144 @@
|
||||
# KẾ HOẠCH SỬA TOÀN BỘ + TÍNH NĂNG GPS GỐC ẢNH CAMERA OS
|
||||
|
||||
App: `RecipesCam` (Expo SDK 57 / RN 0.86 / Skia / vision-camera).
|
||||
Nguyên tắc: sửa nhỏ nhất, từng Phase có tiêu chí verify trên thiết bị thật, không thay đổi stack.
|
||||
|
||||
---
|
||||
|
||||
## 0. TÓM TẮT
|
||||
|
||||
- **Trục trặc chính**: luồng "Save ảnh đã lọc" và "đọc GPS ảnh cũ" đều chết — gồm 1 lỗi API Skia (`toString` không tồn tại) và **1 lỗi tương thích SDK 57 chưa từng được xử lý**: từ `expo-media-library@57`, toàn bộ hàm legacy (`getAssetInfoAsync`, `saveToLibraryAsync`, ...) import từ `expo-media-library` **chỉ là stub ném lỗi** (xem `node_modules/expo-media-library/src/legacyWarnings.ts`); phải import từ `expo-media-library/legacy` hoặc dùng class API mới (`Asset`, `Query`, `Album`).
|
||||
- **Tính năng mới**: đọc & dùng GPS gốc ghi trong EXIF của ảnh do camera hệ điều hành chụp. Chìa khóa Android: cần quyền `ACCESS_MEDIA_LOCATION` (bật qua plugin option `isAccessMediaLocationEnabled`) — nếu không, Android 10+ **giấu** GPS trong metadata.
|
||||
|
||||
---
|
||||
|
||||
## 1. PHÁT HIỆN BỔ SUNG (ngoài báo cáo trước)
|
||||
|
||||
| Lỗi | Bằng chứng |
|
||||
|---|---|
|
||||
| `App.tsx:263 MediaLibrary.getAssetInfoAsync(...)` → **throw ngay** (stub SDK 57) → Tier-2 GPS chưa bao giờ chạy | `legacyWarnings.ts`: `errorOnLegacyMethodUse('getAssetInfoAsync')` |
|
||||
| `exportEngine.ts:183 MediaLibrary.saveToLibraryAsync(...)` → **throw ngay** → kể cả sửa xong B1 (encodeToBase64), save vẫn chết | cùng nguồn: stub `saveToLibraryAsync` |
|
||||
| `exportEngine.ts:181 requestPermissionsAsync` vẫn chạy (hàm mới của module mới) nhưng không đủ | `index.ts` chỉ export mới: `requestPermissionsAsync`, `getPermissionsAsync`, `presentPermissionsPicker`, class `Asset/Query/Album` |
|
||||
| Android 10+: đọc GPS từ MediaStore cần `android.permission.ACCESS_MEDIA_LOCATION` trong manifest, runtime được auto-request khi có trong manifest | plugin `withMediaLibrary.js` option `isAccessMediaLocationEnabled`; `MediaLibraryModule.kt:298-323` |
|
||||
|
||||
→ Kết luận: lỗi B1 + SDK57 phải sửa **cùng nhau**, nếu không ảnh không bao giờ lưu được.
|
||||
|
||||
---
|
||||
|
||||
## 2. PHASE 1 — SỬA LUỒNG XUẤT & LƯU ẢNH (blocker)
|
||||
|
||||
File: `src/utils/exportEngine.ts`
|
||||
|
||||
1. **B1 — base64 sai** (dòng 173-178):
|
||||
- Bỏ `Skia.Data.fromBytes(jpegBytes).toString()` (SkData chỉ có `dispose`, `.toString()` rơi về `[object Object]`).
|
||||
- Dùng API có sẵn: `const base64 = resultImage.encodeToBase64(ImageFormat.JPEG, 95);` (có trong `Image.d.ts:115` + native `JsiSkImage.h`).
|
||||
- Ghi file: `FileSystem.writeAsStringAsync(tempFileUri, base64, { encoding: EncodingType.Base64 })`.
|
||||
2. **SDK57 — save**: đổi import `* as MediaLibrary from 'expo-media-library'` → 2 nguồn:
|
||||
- `import { requestPermissionsAsync } from 'expo-media-library';` (hàm mới, request đủ read + auto `ACCESS_MEDIA_LOCATION` nếu manifest có).
|
||||
- `import { saveToLibraryAsync } from 'expo-media-library/legacy';` (hoặc migrate sang `Asset.create(uri)` — chọn legacy để diff nhỏ, đánh dấu `ponytail:` chuyển class API khi bỏ legacy).
|
||||
3. **Giữ nguyên phần còn lại**: pipeline Skia surface → filter → grain → frame → GPS text đúng hướng, không đụng.
|
||||
|
||||
Verify: build dev, chọn ảnh → SAVE → mở Gallery thấy file JPEG xem được, watermark đúng. (Không cần thiết bị thật đặc biệt; máy Android bất kỳ.)
|
||||
|
||||
---
|
||||
|
||||
## 3. PHASE 2 — TÍNH NĂNG: ĐỌC & DÙNG NATIVE GPS ẢNH CHỤP BỞI CAMERA OS
|
||||
|
||||
### 3.1 Yêu cầu chức năng
|
||||
- Chọn ảnh (bất kể nguồn: thư viện/DCIM do Camera OS chụp, ứng dụng file...) trong **library mode** → geotag watermark phải ưu tiên **tọa độ GPS gốc nhúng trong ảnh**, kèm địa danh (reverse geocode), thời gian chụp ảnh — KHÔNG lấy GPS thiết bị hiện tại.
|
||||
- GPS thiết bị chỉ còn là **phương án cuối** khi: ảnh không có GPS VÀ user đang bật geotag.
|
||||
- Hiển thị nguồn dữ liệu để QA: watermark line 3 thêm tag `[EXIF]`/`[MEDIA]`/`[DEVICE]` (giữ mặc định tắt nếu muốn sạch UI).
|
||||
|
||||
### 3.2 Chuỗi phân giải GPS (thay `resolvePhotoLocation` hiện tại)
|
||||
```
|
||||
Tier EXIF (image-picker asset.exif)
|
||||
→ có GPSLatitude/Longitude? parse chuẩn rational → dùng luôn (source=exif, timestamp=ảnh)
|
||||
Tier MEDIA (asset.assetId != null)
|
||||
→ request read permission (main requestPermissionsAsync)
|
||||
→ MediaLibrary.getAssetInfoAsync(assetId) [import legacy]
|
||||
→ assetInfo.location → dùng (source=mediaLibrary)
|
||||
Tier DEVICE (chỉ khi useGeotag=true)
|
||||
→ getCurrentGPS() → dùng (source=device)
|
||||
Mọi tier: reverse geocode ra locality (uppercase) nếu chưa có; formatCoordinate như cũ
|
||||
```
|
||||
Tách hẳn sang util mới `src/utils/photoGps.ts` (không nhét tiếp vào App.tsx), giữ `App.tsx` chỉ gọi.
|
||||
|
||||
### 3.3 Thay đổi cụ thể
|
||||
1. `app.json` plugin `expo-media-library` thêm: `"isAccessMediaLocationEnabled": true` → sinh `android.permission.ACCESS_MEDIA_LOCATION`.
|
||||
2. `App.tsx:236-280`: xóa logic cũ → `const gps = await resolvePhotoGPS(asset, useGeotag)`.
|
||||
3. Hardening `parseGPS` (bug B7): nhận dạng 3 dạng EXIF:
|
||||
- số `21.5`,
|
||||
- array số `[21, 30, 0]`,
|
||||
- array rational string `["21/1","30/1","0/1"]` → bóc `num/den`.
|
||||
- Trả `null` thay vì `NaN`; chỉ chấp nhận khi `isFinite`; bỏ qua `0,0` (thiếu dữ liệu).
|
||||
4. GPSInfo mở rộng (`src/types/index.ts`): `source?: 'exif' | 'mediaLibrary' | 'device'; photoTakenAt?: number;` — timestamp mặc định = thời điểm chụp ảnh nếu đọc được EXIF.
|
||||
5. Watermark (`Viewfinder.tsx:152-170` + `exportEngine.ts:149-159`): hiển thị giờ từ `gpsInfo.timestamp`; thêm tag nguồn (tham số debug).
|
||||
6. Permission UX: nếu đọc ảnh mà user chưa grant read/location → Alert giải thích + `requestPermissionsAsync()` đúng thời điểm (không hỏi lúc khởi động).
|
||||
|
||||
### 3.4 Ma trận kiểm thử trên máy thật
|
||||
| Nền tảng | Ảnh camera OS có GPS | Kỳ vọng |
|
||||
|---|---|---|
|
||||
| Android 10+ | bật Location khi chụp | cho phép `ACCESS_MEDIA_LOCATION` → watermark = đúng nơi chụp `[MEDIA/EXIF]` |
|
||||
| Android 10+ | không bật Location | không watermark hoặc `[DEVICE]` nếu fallback bật |
|
||||
| Android (Photo Picker mới) | có GPS | Picker thường giữ EXIF khi app có location-access; nếu `assetId=null` → nhảy Tier EXIF; thiếu cả 2 → ghi log + fallback |
|
||||
| iOS full access | có GPS | `assetId` có → Tier MEDIA chạy |
|
||||
| iOS limited access | — | `assetId=null`, exif không kèm GPS → fallback device; ghi chú UX "cấp full quyền để đọc vị trí ảnh" |
|
||||
| File/cloud (không qua thư viện OS) | có GPS trong file | tùy chọn Tier EXIF binary (xem 3.5) |
|
||||
|
||||
### 3.5 (Tùy chọn, sau khi Phase 2 core chạy) EXIF binary fallback
|
||||
Ảnh không qua MediaStore (URI tạm/cloud) mà picker không trả exif: parse GPS IFD trực tiếp từ file bằng parser nhỏ (`exif-reader` — đã ghi trong PROJECT_OVERVIEW, chưa cài) hoặc tự đọc 2 IFD. Chỉ thêm khi có nhu cầu thật (YAGNI).
|
||||
|
||||
---
|
||||
|
||||
## 4. PHASE 3 — BUGS STATE/UI CÒN LẠI
|
||||
|
||||
| # | Sửa | File |
|
||||
|---|---|---|
|
||||
| B3 | **Quyết định rõ số phận 3 slider**: (a) map `contrast` vào ma trận (scale offset, nhân sau base) + highlight/shadow giữ nguyên hoặc (b) ẩn slider khỏi UI để không bán tính năng ảo. Đề xuất (a) contrast; highlight/shadow triển khai bằng `ComponentTransfer` hoặc ẩn tạm — chốt với người dùng | `src/utils/colorUtils.ts`, `src/components/AdjustmentPanel.tsx` |
|
||||
| B3b | Monochrome: đưa bước Exposure **ra trước** early-return mono (WB/tint/chrome vẫn skip), để Exposure slider có tác dụng trên ACROS | `colorUtils.ts:113-115` |
|
||||
| B4 | (Quyết định) Denoise/Clarity hiện chỉ áp lúc export. Tối thiểu: đổi nhãn slider thành "chỉ áp khi SAVE"; tối đa: preview song song bằng ImageFilter Skia. Đề xuất Phase 3 làm nhãn + ghi chú, preview để Phase 4 | `AdjustmentPanel.tsx` |
|
||||
| B5 | Tuần tự hoá init: `restoreSession()` chạy trước, chỉ `applyRecipe(list[0])` khi **không** có session hợp lệ; bỏ `loadRecipes` tự apply mù | `App.tsx:60-122` |
|
||||
| B8 | Tách side-effect khỏi updater: tính `next` trước, rồi `setAdjustments(next)` + `setSelectedRecipe({...})` độc lập | `App.tsx:138-149` |
|
||||
| B6/B9 | (đã gộp vào Phase 1/2) | |
|
||||
| B10 | Nút DONE giữ ý nghĩa đóng; nếu media permission bị từ chối → hiện trạng thái "chưa lưu vào thư viện" + nút LƯU LẠI | `PreviewModal.tsx` |
|
||||
| B11 | Xoá file tạm cũ trước khi ghi mới (cache dir 1 file `camrecipe_pro_export_*.jpg` thôi) | `exportEngine.ts` |
|
||||
| B12 | Restore URI không tồn tại → tự chuyển về `mode='camera'` + reset `libraryImageUri` | `App.tsx:89-114` |
|
||||
| B13 | Dọn quyền thừa: `expo-image-picker` plugin thêm `microphonePermission: false`; bỏ `RECORD_AUDIO/READ_MEDIA_AUDIO` khỏi app.json | `app.json` |
|
||||
| B14 | Kelvin 10000K: chốt có giữ `r=1.55` (kẹt kênh) hay giảm hệ số — quyết định thẩm mỹ, mặc định giữ nguyên | `colorUtils.ts` |
|
||||
| Share | Chuyển `Share.share` file → `expo-sharing` (`shareAsync(uri, {mimeType})`) | `PreviewModal.tsx` |
|
||||
| Typecheck | TS 6.0.3 trong package-lock làm `tsc` crash (`RangeError`); hạ `typescript` về `~5.9` để `npx tsc --noEmit` dùng được | `package.json` |
|
||||
|
||||
---
|
||||
|
||||
## 5. PHASE 4 — CAMERA MODE (cần chốt sản phẩm)
|
||||
|
||||
Hiện shutter camera mode chỉ Alert. Hai hướng:
|
||||
- **(A) Implement chụp thật** bằng vision-camera: `device.takePhoto()` (frame processor đang chạy → photo raw chưa filter) → chạy cùng `processAndExportPhoto()` trên file ảnh vừa chụp → lưu gallery. GPS khi chụp: dùng `getCurrentGPS()` (vision-camera không ghi EXIF GPS) — hoặc chấp nhận ảnh tự chụp không có GPS gốc.
|
||||
- **(B) Khóa UX hiện tại**: ẩn/đổi nút shutter thành hướng dẫn sang LIBRARY.
|
||||
Khuyến nghị (A) nhưng xếp sau Phase 1-3 vì cần thiết bị thật để verify ổn định.
|
||||
|
||||
---
|
||||
|
||||
## 6. QA TỔNG THỂ (sau mỗi Phase)
|
||||
|
||||
```bash
|
||||
cd /home/locpham/RecipesCam
|
||||
npx tsc --noEmit # sau khi hạ TS ~5.9 (Phase 3)
|
||||
npx expo run:android # máy thật USB (không có emulator image trong máy này)
|
||||
```
|
||||
|
||||
Checklist thủ công:
|
||||
1. Library → chọn ảnh bất kỳ → kéo Exposure/Sat/WB → SAVE → Gallery mở file đẹp, đúng filter + frame + watermark (Phase 1).
|
||||
2. Dùng **Camera OS chụp ảnh mới với Location ON** → chọn trong app → watermark hiện đúng nơi chụp + `[EXIF/MEDIA]` (Phase 2).
|
||||
3. Lặp lại khi ảnh chụp Location OFF → không watermark / `[DEVICE]` đúng chính sách.
|
||||
4. Background app giữa lúc chỉnh → mở lại → recipe + slider khôi phục đúng (B5).
|
||||
5. Chọn ACROS MONO → kéo Exposure → ảnh sáng/tối đổi (B3b).
|
||||
6. Toggle geotag tắt → không watermark; bật lại khi chưa grant location → Alert + fallback đúng.
|
||||
7. Chụp 2 ảnh liên tiếp → không đè file, không treo (B1/B11).
|
||||
8. SHARE ảnh → mở được qua app khác (expo-sharing).
|
||||
|
||||
Rủi ro: (i) GPS Photo Picker Android/iOS limited-access hành vi khác nhau → giữ log + QA ma trận 3.4; (ii) vision-camera trên máy thiếu GPU frame processor cũ vẫn preview được nhưng `takePhoto` cần test riêng; (iii) `expo-media-library/legacy` sẽ bị gỡ ở SDK tương lai → đã đánh dấu `ponytail:`.
|
||||
|
||||
## 7. THỨ TỰ THỰC HIỆN
|
||||
Phase 1 → verify → Phase 2 (+app.json/plugin) → verify → Phase 3 → verify → Phase 4 (cần chốt A/B).
|
||||
Mỗi Phase là 1 commit riêng, giữ diff nhỏ.
|
||||
@@ -19,15 +19,13 @@
|
||||
"predictiveBackGestureEnabled": false,
|
||||
"permissions": [
|
||||
"android.permission.CAMERA",
|
||||
"android.permission.RECORD_AUDIO",
|
||||
"android.permission.ACCESS_COARSE_LOCATION",
|
||||
"android.permission.ACCESS_FINE_LOCATION",
|
||||
"android.permission.READ_EXTERNAL_STORAGE",
|
||||
"android.permission.WRITE_EXTERNAL_STORAGE",
|
||||
"android.permission.READ_MEDIA_VISUAL_USER_SELECTED",
|
||||
"android.permission.READ_MEDIA_IMAGES",
|
||||
"android.permission.READ_MEDIA_VIDEO",
|
||||
"android.permission.READ_MEDIA_AUDIO"
|
||||
"android.permission.READ_MEDIA_VIDEO"
|
||||
],
|
||||
"package": "com.locphamtran.recipescamera"
|
||||
},
|
||||
@@ -37,9 +35,10 @@
|
||||
"plugins": [
|
||||
"expo-asset",
|
||||
[
|
||||
"expo-camera",
|
||||
"react-native-vision-camera",
|
||||
{
|
||||
"cameraPermission": "Allow CamRecipe Pro to access your camera to take photos."
|
||||
"enableFrameProcessors": true,
|
||||
"cameraPermissionText": "Allow CamRecipe Pro to access your camera."
|
||||
}
|
||||
],
|
||||
[
|
||||
@@ -51,15 +50,18 @@
|
||||
[
|
||||
"expo-media-library",
|
||||
{
|
||||
"photosPermission": "Allow CamRecipe Pro to save processed photos to your library."
|
||||
"photosPermission": "Allow CamRecipe Pro to save processed photos to your library.",
|
||||
"isAccessMediaLocationEnabled": true
|
||||
}
|
||||
],
|
||||
[
|
||||
"expo-image-picker",
|
||||
{
|
||||
"photosPermission": "Allow CamRecipe Pro to select photos from your library to apply presets."
|
||||
"photosPermission": "Allow CamRecipe Pro to select photos from your library to apply presets.",
|
||||
"microphonePermission": false
|
||||
}
|
||||
]
|
||||
],
|
||||
"expo-sharing"
|
||||
],
|
||||
"extra": {
|
||||
"eas": {
|
||||
|
||||
Generated
+124
-75
@@ -12,7 +12,7 @@
|
||||
"@react-native-community/slider": "5.2.0",
|
||||
"@shopify/react-native-skia": "2.6.2",
|
||||
"babel-preset-expo": "~57.0.0",
|
||||
"expo": "~57.0.6",
|
||||
"expo": "~57.0.7",
|
||||
"expo-asset": "~57.0.5",
|
||||
"expo-camera": "~57.0.2",
|
||||
"expo-dev-client": "~57.0.6",
|
||||
@@ -22,19 +22,23 @@
|
||||
"expo-image-picker": "~57.0.4",
|
||||
"expo-location": "~57.0.4",
|
||||
"expo-media-library": "~57.0.2",
|
||||
"expo-sharing": "~57.0.18",
|
||||
"expo-status-bar": "~57.0.1",
|
||||
"lucide-react-native": "^1.24.0",
|
||||
"nativewind": "^4.2.6",
|
||||
"react": "19.2.3",
|
||||
"react-dom": "19.2.3",
|
||||
"react-native": "0.86.0",
|
||||
"react-native-reanimated": "4.5.0",
|
||||
"react-native-reanimated": "^4.5.0",
|
||||
"react-native-safe-area-context": "^5.8.0",
|
||||
"react-native-vision-camera": "^4.7.3",
|
||||
"react-native-web": "^0.21.2",
|
||||
"react-native-worklets-core": "^1.6.3",
|
||||
"tailwindcss": "^3.4.19"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "~19.2.2",
|
||||
"typescript": "~6.0.3"
|
||||
"typescript": "^5.9.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@alloc/quick-lru": {
|
||||
@@ -1218,15 +1222,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@expo/config-plugins": {
|
||||
"version": "57.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@expo/config-plugins/-/config-plugins-57.0.5.tgz",
|
||||
"integrity": "sha512-xhUGgzpFWRghDUH98+Wl4RDakYhTsbyMg6aOYiBjRzPO/THH8tKMw3vlksgFYlU2PkiAdABJN3tNPf5qmvOQhA==",
|
||||
"version": "57.0.9",
|
||||
"resolved": "https://registry.npmjs.org/@expo/config-plugins/-/config-plugins-57.0.9.tgz",
|
||||
"integrity": "sha512-hHgfL1avkCdEvDSw7IwlKwRYYNgcxzbNNMIk6W6lTkJpY0MajinAfeJUS0J+wPCsjUfGbVqOJM+XhPaO5ulUxg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@expo/config-types": "^57.0.2",
|
||||
"@expo/json-file": "~11.0.1",
|
||||
"@expo/plist": "^0.8.1",
|
||||
"@expo/require-utils": "^57.0.3",
|
||||
"@expo/require-utils": "^57.0.5",
|
||||
"@expo/sdk-runtime-versions": "^1.0.0",
|
||||
"chalk": "^4.1.2",
|
||||
"debug": "^4.3.5",
|
||||
@@ -1421,9 +1425,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@expo/metro-config": {
|
||||
"version": "57.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@expo/metro-config/-/metro-config-57.0.5.tgz",
|
||||
"integrity": "sha512-KyiYvQ9rwT3X3CFTRP1grSs/z2gizJuKbo7akPCgqACC4XtG/MWYsvbOB2fH6cfeRn8Zv3JNHgRGF4AmCOIzFw==",
|
||||
"version": "57.0.6",
|
||||
"resolved": "https://registry.npmjs.org/@expo/metro-config/-/metro-config-57.0.6.tgz",
|
||||
"integrity": "sha512-liXA9axM3aykAdil4qdHOYKmQqTDdXYkAoT3Eny+SQEo3btERJCbOk8VH48/G0sVbXCj82AbSS8s3XNEQNqbDQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.20.0",
|
||||
@@ -1511,9 +1515,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@expo/prebuild-config": {
|
||||
"version": "57.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@expo/prebuild-config/-/prebuild-config-57.0.7.tgz",
|
||||
"integrity": "sha512-VrsRKc+je3bAZp8ocM8fVjRbXAQqYnBLiy3gm10VY0WoNspQwxcE9gllTu2jtMcqf6pNfAxGaSSp9I74SNOEzg==",
|
||||
"version": "57.0.8",
|
||||
"resolved": "https://registry.npmjs.org/@expo/prebuild-config/-/prebuild-config-57.0.8.tgz",
|
||||
"integrity": "sha512-NQjRuTLvxUnggK57pKTHLtzS8YYtjrQSGYu+CjgWaO8lNlqdoBbvHrxGYtc9tBuOI3xVPaTfuIvjikxX1r+UhQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@expo/config": "~57.0.5",
|
||||
@@ -1523,15 +1527,15 @@
|
||||
"@expo/json-file": "^11.0.1",
|
||||
"@react-native/normalize-colors": "0.86.0",
|
||||
"debug": "^4.3.1",
|
||||
"expo-modules-autolinking": "~57.0.7",
|
||||
"expo-modules-autolinking": "~57.0.8",
|
||||
"resolve-from": "^5.0.0",
|
||||
"semver": "^7.6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@expo/require-utils": {
|
||||
"version": "57.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@expo/require-utils/-/require-utils-57.0.3.tgz",
|
||||
"integrity": "sha512-ns05X1K8tM+Qtzp6dNloUFOopSdh3J+HC61BtOR8WHhgtPFyX8TKuO2diqZUqVg9K8yfkWug7g8tBS0qRniSTA==",
|
||||
"version": "57.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@expo/require-utils/-/require-utils-57.0.5.tgz",
|
||||
"integrity": "sha512-kTAXj9lDFEIPMsbAOGCGbjBbMF0oi7CqkYM79KOX0DDD9wSwXmlKL1z2h8OwsrBf7mbOo2DjlRvZu4BEjrIxGw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.20.0",
|
||||
@@ -1539,7 +1543,7 @@
|
||||
"@babel/plugin-transform-modules-commonjs": "^7.24.8"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": "^5.0.0 || ^5.0.0-0 || ^6.0.0"
|
||||
"typescript": "^5.0.0 || ^5.0.0-0 || ^6.0.0 || ^7.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"typescript": {
|
||||
@@ -2166,9 +2170,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/agent-cli-detector": {
|
||||
"version": "0.1.2",
|
||||
"resolved": "https://registry.npmjs.org/agent-cli-detector/-/agent-cli-detector-0.1.2.tgz",
|
||||
"integrity": "sha512-qdZ/9JFORtTKJNhT/IczMeEfEUbUU0K5umYeiIQHX+AjHs+Y9SXVzSgaYlpZeyNMrvuh2HpZiOTpvS57iPfBkQ==",
|
||||
"version": "0.1.3",
|
||||
"resolved": "https://registry.npmjs.org/agent-cli-detector/-/agent-cli-detector-0.1.3.tgz",
|
||||
"integrity": "sha512-XjBe6lT5sK9xhAn0ToFvdwicsaGvzZkax1iviPnL8sFFYHoagnTTn2E4YTFiyM956iqkOkFcgcWCiER0kkShbQ==",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"agent-cli-detector": "dist/cli.js"
|
||||
@@ -3311,13 +3315,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/expo": {
|
||||
"version": "57.0.6",
|
||||
"resolved": "https://registry.npmjs.org/expo/-/expo-57.0.6.tgz",
|
||||
"integrity": "sha512-4NKM1ArfRAmmY82Xcw/guGHlTSItD5mzNxbK4Qd3aOPuRAPAkCgJVLC/eqccZCEGS5aJn0tyjHNKE65k2GrcFw==",
|
||||
"version": "57.0.7",
|
||||
"resolved": "https://registry.npmjs.org/expo/-/expo-57.0.7.tgz",
|
||||
"integrity": "sha512-PJdE0EjoX878OqClmsigVKdT0jCNOAQDRLcvFkeBakxaVyEDhjcQ8baKq2YysYH2g0YNB1rEIpHUiKtluO918A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.20.0",
|
||||
"@expo/cli": "^57.0.8",
|
||||
"@expo/cli": "^57.0.9",
|
||||
"@expo/config": "~57.0.5",
|
||||
"@expo/config-plugins": "~57.0.5",
|
||||
"@expo/devtools": "~57.0.1",
|
||||
@@ -3326,16 +3330,16 @@
|
||||
"@expo/local-build-cache-provider": "^57.0.4",
|
||||
"@expo/log-box": "^57.0.1",
|
||||
"@expo/metro": "~56.0.0",
|
||||
"@expo/metro-config": "~57.0.5",
|
||||
"@expo/metro-config": "~57.0.6",
|
||||
"@ungap/structured-clone": "^1.3.0",
|
||||
"babel-preset-expo": "~57.0.3",
|
||||
"expo-asset": "~57.0.5",
|
||||
"expo-constants": "~57.0.5",
|
||||
"expo-asset": "~57.0.6",
|
||||
"expo-constants": "~57.0.6",
|
||||
"expo-file-system": "~57.0.1",
|
||||
"expo-font": "~57.0.1",
|
||||
"expo-keep-awake": "~57.0.1",
|
||||
"expo-modules-autolinking": "~57.0.7",
|
||||
"expo-modules-core": "~57.0.5",
|
||||
"expo-modules-autolinking": "~57.0.8",
|
||||
"expo-modules-core": "~57.0.6",
|
||||
"pretty-format": "^29.7.0",
|
||||
"react-refresh": "^0.14.2",
|
||||
"whatwg-url-minimum": "^0.1.2"
|
||||
@@ -3373,13 +3377,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/expo-asset": {
|
||||
"version": "57.0.5",
|
||||
"resolved": "https://registry.npmjs.org/expo-asset/-/expo-asset-57.0.5.tgz",
|
||||
"integrity": "sha512-vRwG+QvoW1u1vd6yY1oQ4f5doNGGpuCQHvzSY25eCNlQjNJtHJyjWcllzn1KdQ8aQc9TMMYedxeLoYZshSI7MA==",
|
||||
"version": "57.0.6",
|
||||
"resolved": "https://registry.npmjs.org/expo-asset/-/expo-asset-57.0.6.tgz",
|
||||
"integrity": "sha512-n3Yb1VxcP+BMRTyC4R1x2It4+m5EDkNXiVCHGWbnIREQUUkMs2Yeul7D5qfFWAYtIn2Z3hbGMndwU6Az1FPSEg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@expo/image-utils": "^0.11.3",
|
||||
"expo-constants": "~57.0.5"
|
||||
"expo-constants": "~57.0.6"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"expo": "*",
|
||||
@@ -3408,9 +3412,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/expo-constants": {
|
||||
"version": "57.0.5",
|
||||
"resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-57.0.5.tgz",
|
||||
"integrity": "sha512-HVxPZc1uBdqrlcmNvdyO3L107vt/gsCRNGvXrYXWjZqmz1XOvGeUCR7S3MG4wUj4cQw4/WMCp+fcoDIhynJ80A==",
|
||||
"version": "57.0.6",
|
||||
"resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-57.0.6.tgz",
|
||||
"integrity": "sha512-OV+4XUshdO18TKNlo1cxUkXeJWgUOPgalvl8ofmc7kmPPHoyfz2hGJ94tyY/RND/GG5RREE+me9YHClNEzo+Ow==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@expo/env": "~2.4.2"
|
||||
@@ -3578,9 +3582,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/expo-modules-autolinking": {
|
||||
"version": "57.0.7",
|
||||
"resolved": "https://registry.npmjs.org/expo-modules-autolinking/-/expo-modules-autolinking-57.0.7.tgz",
|
||||
"integrity": "sha512-arYvWy3odY0JxFwjosVeu8OrS/CvYy+Voroe126X/fYlkdXryJZSYbOsmM1ruol3Eo4ji8HKcfO+mvEVKGxUbQ==",
|
||||
"version": "57.0.8",
|
||||
"resolved": "https://registry.npmjs.org/expo-modules-autolinking/-/expo-modules-autolinking-57.0.8.tgz",
|
||||
"integrity": "sha512-YBDgbJHlhhhr3JaKErW6znsIL8zQsh206LaDpWrLHV/WluRwhYfZuF/fhkVN7b/DsRxIu0UeCPExaEPMxlC2Hw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@expo/require-utils": "^57.0.3",
|
||||
@@ -3593,9 +3597,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/expo-modules-core": {
|
||||
"version": "57.0.5",
|
||||
"resolved": "https://registry.npmjs.org/expo-modules-core/-/expo-modules-core-57.0.5.tgz",
|
||||
"integrity": "sha512-jyx2yAKUO5wJRlRTj74GC8P6NFhXWw/wB42LaPgAco7tjvWujl4O5D8fM7jCE+VFRa3OV/4SZUyp7H0aeuqYdg==",
|
||||
"version": "57.0.6",
|
||||
"resolved": "https://registry.npmjs.org/expo-modules-core/-/expo-modules-core-57.0.6.tgz",
|
||||
"integrity": "sha512-hePwOh2+i+EpWrVnv95sQeQ0OD5PYuEuIhmglVLzQVaVBV3zHcRvLAc1rV8ROqUL6YtAQUXjPkoSx1ly2ZdTuQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@expo/expo-modules-macros-plugin": "0.6.1",
|
||||
@@ -3631,6 +3635,22 @@
|
||||
"node": ">=20.16.0"
|
||||
}
|
||||
},
|
||||
"node_modules/expo-sharing": {
|
||||
"version": "57.0.18",
|
||||
"resolved": "https://registry.npmjs.org/expo-sharing/-/expo-sharing-57.0.18.tgz",
|
||||
"integrity": "sha512-Q09Gab1yn+OLR7sqcHBNyjM/4MMQa290hDKCV2pf2THnTLkLLuUmC0AKgMgxygqU0uuzhOgVqfnE70dePCLq9A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@expo/config-plugins": "^57.0.9",
|
||||
"@expo/config-types": "^57.0.2",
|
||||
"@expo/plist": "^0.8.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"expo": "*",
|
||||
"react": "*",
|
||||
"react-native": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/expo-status-bar": {
|
||||
"version": "57.0.1",
|
||||
"resolved": "https://registry.npmjs.org/expo-status-bar/-/expo-status-bar-57.0.1.tgz",
|
||||
@@ -3652,9 +3672,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/expo/node_modules/@expo/cli": {
|
||||
"version": "57.0.8",
|
||||
"resolved": "https://registry.npmjs.org/@expo/cli/-/cli-57.0.8.tgz",
|
||||
"integrity": "sha512-RzE6o39O6Ual4nw1LbUyrIMY5VzQlW4CbOyoww/RhKclMRVItnfkLi89cVKQoPFw+5EKW2KKC6LLtA4or7Trwg==",
|
||||
"version": "57.0.9",
|
||||
"resolved": "https://registry.npmjs.org/@expo/cli/-/cli-57.0.9.tgz",
|
||||
"integrity": "sha512-41z9z68SynNXasZOjuT1si5Sq5OKL6SLf40ZjikbtZgDuvBO8HaUsaDzsJ0c1UZJ3N+vMzYCc1JUIDyRkVBjkA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@expo/code-signing-certificates": "^0.0.6",
|
||||
@@ -3667,12 +3687,12 @@
|
||||
"@expo/json-file": "^11.0.1",
|
||||
"@expo/log-box": "^57.0.1",
|
||||
"@expo/metro": "~56.0.0",
|
||||
"@expo/metro-config": "~57.0.5",
|
||||
"@expo/metro-config": "~57.0.6",
|
||||
"@expo/metro-file-map": "^57.0.1",
|
||||
"@expo/osascript": "^2.7.1",
|
||||
"@expo/package-manager": "^1.13.1",
|
||||
"@expo/plist": "^0.8.1",
|
||||
"@expo/prebuild-config": "^57.0.7",
|
||||
"@expo/prebuild-config": "^57.0.8",
|
||||
"@expo/require-utils": "^57.0.3",
|
||||
"@expo/router-server": "^57.0.3",
|
||||
"@expo/schema-utils": "^57.0.2",
|
||||
@@ -4757,9 +4777,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -4780,9 +4797,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -4803,9 +4817,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -4826,9 +4837,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -6472,9 +6480,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -6495,9 +6500,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -6518,9 +6520,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -6541,9 +6540,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -6622,6 +6618,16 @@
|
||||
"react-native-worklets": "0.10.x"
|
||||
}
|
||||
},
|
||||
"node_modules/react-native-safe-area-context": {
|
||||
"version": "5.8.0",
|
||||
"resolved": "https://registry.npmjs.org/react-native-safe-area-context/-/react-native-safe-area-context-5.8.0.tgz",
|
||||
"integrity": "sha512-t+ZsAVzY/wWzzx34vqGbo3/as9EEESJdbyZNL7Yg5EYX+toYMtMqFoDDCvqZUi35eeGVsXc6pAaEk4edMwbuCQ==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"react": "*",
|
||||
"react-native": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/react-native-skia-android": {
|
||||
"version": "147.1.0",
|
||||
"resolved": "https://registry.npmjs.org/react-native-skia-android/-/react-native-skia-android-147.1.0.tgz",
|
||||
@@ -6661,6 +6667,30 @@
|
||||
"react-native": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/react-native-vision-camera": {
|
||||
"version": "4.7.3",
|
||||
"resolved": "https://registry.npmjs.org/react-native-vision-camera/-/react-native-vision-camera-4.7.3.tgz",
|
||||
"integrity": "sha512-g1/neOyjSqn1kaAa2FxI/qp5KzNvPcF0bnQw6NntfbxH6tm0+8WFZszlgb5OV+iYlB6lFUztCbDtyz5IpL47OA==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@shopify/react-native-skia": "*",
|
||||
"react": "*",
|
||||
"react-native": "*",
|
||||
"react-native-reanimated": "*",
|
||||
"react-native-worklets-core": "*"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@shopify/react-native-skia": {
|
||||
"optional": true
|
||||
},
|
||||
"react-native-reanimated": {
|
||||
"optional": true
|
||||
},
|
||||
"react-native-worklets-core": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/react-native-web": {
|
||||
"version": "0.21.2",
|
||||
"resolved": "https://registry.npmjs.org/react-native-web/-/react-native-web-0.21.2.tgz",
|
||||
@@ -6720,6 +6750,19 @@
|
||||
"react-native": "0.83 - 0.86"
|
||||
}
|
||||
},
|
||||
"node_modules/react-native-worklets-core": {
|
||||
"version": "1.6.3",
|
||||
"resolved": "https://registry.npmjs.org/react-native-worklets-core/-/react-native-worklets-core-1.6.3.tgz",
|
||||
"integrity": "sha512-r3Q40XQBccx/iAI5tlyiua+micvO1UGzzUOskNweZUXyfrrE+rb5aqxqruBPqXf90rO+bBiplylLMEAXCLTyGA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"string-hash-64": "^1.0.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "*",
|
||||
"react-native": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/react-native/node_modules/commander": {
|
||||
"version": "12.1.0",
|
||||
"resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz",
|
||||
@@ -7251,6 +7294,12 @@
|
||||
"node": ">= 0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/string-hash-64": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/string-hash-64/-/string-hash-64-1.0.3.tgz",
|
||||
"integrity": "sha512-D5OKWKvDhyVWWn2x5Y9b+37NUllks34q1dCDhk/vYcso9fmhs+Tl3KR/gE4v5UNj2UA35cnX4KdVVGkG1deKqw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/string-width": {
|
||||
"version": "4.2.3",
|
||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
||||
@@ -7544,9 +7593,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "6.0.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz",
|
||||
"integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==",
|
||||
"version": "5.9.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
|
||||
+7
-3
@@ -7,7 +7,7 @@
|
||||
"@react-native-community/slider": "5.2.0",
|
||||
"@shopify/react-native-skia": "2.6.2",
|
||||
"babel-preset-expo": "~57.0.0",
|
||||
"expo": "~57.0.6",
|
||||
"expo": "~57.0.7",
|
||||
"expo-asset": "~57.0.5",
|
||||
"expo-camera": "~57.0.2",
|
||||
"expo-dev-client": "~57.0.6",
|
||||
@@ -17,19 +17,23 @@
|
||||
"expo-image-picker": "~57.0.4",
|
||||
"expo-location": "~57.0.4",
|
||||
"expo-media-library": "~57.0.2",
|
||||
"expo-sharing": "~57.0.18",
|
||||
"expo-status-bar": "~57.0.1",
|
||||
"lucide-react-native": "^1.24.0",
|
||||
"nativewind": "^4.2.6",
|
||||
"react": "19.2.3",
|
||||
"react-dom": "19.2.3",
|
||||
"react-native": "0.86.0",
|
||||
"react-native-reanimated": "4.5.0",
|
||||
"react-native-reanimated": "^4.5.0",
|
||||
"react-native-safe-area-context": "^5.8.0",
|
||||
"react-native-vision-camera": "^4.7.3",
|
||||
"react-native-web": "^0.21.2",
|
||||
"react-native-worklets-core": "^1.6.3",
|
||||
"tailwindcss": "^3.4.19"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "~19.2.2",
|
||||
"typescript": "~6.0.3"
|
||||
"typescript": "^5.9.3"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "expo start",
|
||||
|
||||
@@ -242,16 +242,13 @@ export default function AdjustmentPanel({
|
||||
case 'filters':
|
||||
return (
|
||||
<ScrollView className="flex-1 bg-carbon py-3">
|
||||
{renderSlider('Highlight Contrast', adjustments.highlight, -10, 10, 1, (val) =>
|
||||
onUpdateAdjustments({ highlight: val })
|
||||
)}
|
||||
{renderSlider('Shadow Contrast', adjustments.shadow, -10, 10, 1, (val) =>
|
||||
onUpdateAdjustments({ shadow: val })
|
||||
)}
|
||||
{renderSlider('Denoise (Blur)', adjustments.denoise, 0, 10, 1, (val) =>
|
||||
<Text className="text-zinc-600 font-mono text-[10px] px-4 mb-2">
|
||||
NOTE: DENOISE & CLARITY APPLY AT EXPORT ONLY — LIVE PREVIEW UNCHANGED
|
||||
</Text>
|
||||
{renderSlider('Denoise (Export)', adjustments.denoise, 0, 10, 1, (val) =>
|
||||
onUpdateAdjustments({ denoise: val })
|
||||
)}
|
||||
{renderSlider('Clarity (Sharp/Soft)', adjustments.clarity, -10, 10, 1, (val) =>
|
||||
{renderSlider('Clarity (Export)', adjustments.clarity, -10, 10, 1, (val) =>
|
||||
onUpdateAdjustments({ clarity: val })
|
||||
)}
|
||||
{renderSlider('Monochrome Grain', adjustments.grain, 0, 10, 1, (val) =>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from 'react';
|
||||
import { View, Text, TouchableOpacity, Image } from 'react-native';
|
||||
import { Image as ImageIcon, Eye } from 'lucide-react-native';
|
||||
import { Image as ImageIcon, Eye, Download, Camera } from 'lucide-react-native';
|
||||
|
||||
interface CameraControlsProps {
|
||||
lastPhotoUri: string | null;
|
||||
@@ -8,6 +8,7 @@ interface CameraControlsProps {
|
||||
onPickImage: () => void;
|
||||
onOpenPreview: () => void;
|
||||
isLibraryMode: boolean;
|
||||
onSwitchToCamera?: () => void;
|
||||
}
|
||||
|
||||
export default function CameraControls({
|
||||
@@ -16,6 +17,7 @@ export default function CameraControls({
|
||||
onPickImage,
|
||||
onOpenPreview,
|
||||
isLibraryMode,
|
||||
onSwitchToCamera,
|
||||
}: CameraControlsProps) {
|
||||
return (
|
||||
<View className="flex-row items-center justify-between px-8 py-6 bg-carbon border-t border-zinc-800/50">
|
||||
@@ -28,28 +30,36 @@ export default function CameraControls({
|
||||
<ImageIcon size={20} color="#f59e0b" />
|
||||
</TouchableOpacity>
|
||||
|
||||
{/* Shutter Button */}
|
||||
{/* Dynamic Center Button: Shutter or Save */}
|
||||
<TouchableOpacity
|
||||
onPress={onCapture}
|
||||
disabled={isLibraryMode}
|
||||
className={`w-20 h-20 rounded-full border-4 border-zinc-800 flex items-center justify-center ${
|
||||
isLibraryMode ? 'opacity-40' : 'opacity-100'
|
||||
}`}
|
||||
className="relative items-center justify-center"
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
<View className="w-16 h-16 rounded-full bg-white border border-black flex items-center justify-center active:scale-95">
|
||||
<View className="w-14 h-14 rounded-full bg-zinc-100/90 border border-zinc-200" />
|
||||
</View>
|
||||
{isLibraryMode ? (
|
||||
<View className="w-16 h-16 rounded-full bg-amber-500 items-center justify-center border-4 border-zinc-800">
|
||||
<Download size={28} color="#000000" />
|
||||
</View>
|
||||
) : (
|
||||
<View className="w-20 h-20 rounded-full border-4 border-zinc-800 flex items-center justify-center">
|
||||
<View className="w-16 h-16 rounded-full bg-white border border-black flex items-center justify-center">
|
||||
<View className="w-14 h-14 rounded-full bg-zinc-100/90 border border-zinc-200" />
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
|
||||
{/* Last Captured Image Preview Button */}
|
||||
{/* Right Action Button: Eye (preview) or Camera (switch back) */}
|
||||
<TouchableOpacity
|
||||
onPress={onOpenPreview}
|
||||
disabled={!lastPhotoUri}
|
||||
className="w-12 h-12 rounded-full bg-titan border border-zinc-800 flex items-center justify-center overflow-hidden"
|
||||
onPress={isLibraryMode && onSwitchToCamera ? onSwitchToCamera : onOpenPreview}
|
||||
className={`w-12 h-12 rounded-full bg-titan border border-zinc-800 flex items-center justify-center overflow-hidden ${
|
||||
isLibraryMode ? '' : ''
|
||||
}`}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
{lastPhotoUri ? (
|
||||
{isLibraryMode ? (
|
||||
<Camera size={20} color="#f59e0b" />
|
||||
) : lastPhotoUri ? (
|
||||
<Image source={{ uri: lastPhotoUri }} className="w-full h-full object-cover" />
|
||||
) : (
|
||||
<Eye size={20} color="#a1a1aa" />
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import React from 'react';
|
||||
import { View, Text, Modal, Image, TouchableOpacity, Share } from 'react-native';
|
||||
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 {
|
||||
@@ -7,6 +9,7 @@ interface PreviewModalProps {
|
||||
onClose: () => void;
|
||||
photoUri: string | null;
|
||||
recipeName: string;
|
||||
savedToLibrary: boolean; // whether the export was already saved to the gallery
|
||||
}
|
||||
|
||||
export default function PreviewModal({
|
||||
@@ -14,32 +17,86 @@ export default function PreviewModal({
|
||||
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 {
|
||||
await Share.share({
|
||||
url: photoUri,
|
||||
message: `Captured with CamRecipe Pro - Preset: ${recipeName}`,
|
||||
});
|
||||
// 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={onClose}
|
||||
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={onClose}
|
||||
onPress={handleClose}
|
||||
className="w-8 h-8 rounded-full bg-zinc-900 flex items-center justify-center"
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
@@ -61,25 +118,34 @@ export default function PreviewModal({
|
||||
</View>
|
||||
|
||||
{/* Bottom bar */}
|
||||
<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>
|
||||
<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={onClose}
|
||||
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">DONE</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>
|
||||
|
||||
+133
-55
@@ -1,10 +1,9 @@
|
||||
import React from 'react';
|
||||
import { View, Text, StyleSheet, Dimensions, ActivityIndicator, TouchableOpacity } from 'react-native';
|
||||
import { CameraView } from 'expo-camera';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { View, Text, StyleSheet, Dimensions, TouchableOpacity } from 'react-native';
|
||||
import { Camera, useCameraDevice, useSkiaFrameProcessor, type CameraDevice } from 'react-native-vision-camera';
|
||||
import { useSharedValue } from 'react-native-reanimated';
|
||||
import {
|
||||
Canvas,
|
||||
Image as SkiaImage,
|
||||
ColorMatrix,
|
||||
useImage,
|
||||
useFont,
|
||||
Text as SkiaText,
|
||||
@@ -13,6 +12,8 @@ import {
|
||||
Skia,
|
||||
Group,
|
||||
Rect,
|
||||
ColorMatrix,
|
||||
Image as SkiaImage,
|
||||
} from '@shopify/react-native-skia';
|
||||
import { Recipe, GPSInfo, FrameId } from '../types';
|
||||
import { getSkiaColorMatrix } from '../utils/colorUtils';
|
||||
@@ -27,11 +28,12 @@ interface ViewfinderProps {
|
||||
libraryImageUri: string | null;
|
||||
cameraPermissionGranted: boolean;
|
||||
onRequestCameraPermission: () => void;
|
||||
cameraRef?: React.RefObject<Camera | null>;
|
||||
}
|
||||
|
||||
const { width: screenWidth } = Dimensions.get('window');
|
||||
const viewfinderWidth = screenWidth - 32; // padding sides
|
||||
const viewfinderHeight = (viewfinderWidth * 4) / 3; // 3:4 aspect ratio
|
||||
const viewfinderWidth = screenWidth - 32;
|
||||
const viewfinderHeight = (viewfinderWidth * 4) / 3;
|
||||
|
||||
const noiseEffect = Skia.RuntimeEffect.Make(`
|
||||
vec4 main(vec2 pos) {
|
||||
@@ -40,6 +42,8 @@ const noiseEffect = Skia.RuntimeEffect.Make(`
|
||||
}
|
||||
`);
|
||||
|
||||
const containerStyle = { width: viewfinderWidth, height: viewfinderHeight };
|
||||
|
||||
export default function Viewfinder({
|
||||
mode,
|
||||
recipe,
|
||||
@@ -49,20 +53,45 @@ export default function Viewfinder({
|
||||
libraryImageUri,
|
||||
cameraPermissionGranted,
|
||||
onRequestCameraPermission,
|
||||
cameraRef,
|
||||
}: ViewfinderProps) {
|
||||
// Load library image if available
|
||||
const hookedDevice = useCameraDevice('back');
|
||||
// VisionCamera fetches the device list once when this component mounts. When the
|
||||
// camera permission is granted *after* mount (first-run flow: GRANT button, then
|
||||
// OS dialog), that list stays empty until remount/restart, so the camera would
|
||||
// report "not available". Poll the native list briefly after permission is
|
||||
// granted and prefer a freshly found device.
|
||||
const [grantedDevice, setGrantedDevice] = useState<CameraDevice | undefined>();
|
||||
useEffect(() => {
|
||||
if (!cameraPermissionGranted) return;
|
||||
let stop = false;
|
||||
const iv = setInterval(() => {
|
||||
const back = Camera.getAvailableCameraDevices().find((d) => d.position === 'back');
|
||||
if (back && !stop) {
|
||||
setGrantedDevice(back);
|
||||
clearInterval(iv);
|
||||
clearTimeout(to);
|
||||
}
|
||||
}, 250);
|
||||
const to = setTimeout(() => clearInterval(iv), 6000);
|
||||
return () => {
|
||||
stop = true;
|
||||
clearInterval(iv);
|
||||
clearTimeout(to);
|
||||
};
|
||||
}, [cameraPermissionGranted]);
|
||||
const device = grantedDevice ?? hookedDevice;
|
||||
const skiaImage = useImage(libraryImageUri || '');
|
||||
|
||||
// Load Courier Prime font for the GPS watermark
|
||||
const customFont = useFont(
|
||||
require('../../assets/CourierPrime-Regular.ttf'),
|
||||
Math.round(viewfinderWidth * 0.032) // ~12-14px scaled dynamically
|
||||
Math.round(viewfinderWidth * 0.032)
|
||||
);
|
||||
|
||||
const adjustments = recipe.adjustments;
|
||||
const colorMatrix = getSkiaColorMatrix(
|
||||
recipe.baseFilter,
|
||||
adjustments.exposure,
|
||||
adjustments.contrast,
|
||||
adjustments.saturation,
|
||||
adjustments.temperature,
|
||||
adjustments.tint,
|
||||
@@ -70,6 +99,35 @@ export default function Viewfinder({
|
||||
);
|
||||
const grainOpacity = adjustments.grain / 20;
|
||||
|
||||
const colorMatrixSV = useSharedValue(colorMatrix);
|
||||
|
||||
useEffect(() => {
|
||||
colorMatrixSV.value = getSkiaColorMatrix(
|
||||
recipe.baseFilter,
|
||||
adjustments.exposure,
|
||||
adjustments.contrast,
|
||||
adjustments.saturation,
|
||||
adjustments.temperature,
|
||||
adjustments.tint,
|
||||
adjustments.colorChrome
|
||||
);
|
||||
}, [
|
||||
recipe.baseFilter,
|
||||
adjustments.exposure,
|
||||
adjustments.contrast,
|
||||
adjustments.saturation,
|
||||
adjustments.temperature,
|
||||
adjustments.tint,
|
||||
adjustments.colorChrome,
|
||||
]);
|
||||
|
||||
const frameProcessor = useSkiaFrameProcessor((frame) => {
|
||||
'worklet';
|
||||
const paint = Skia.Paint();
|
||||
paint.setColorFilter(Skia.ColorFilter.MakeMatrix(colorMatrixSV.value));
|
||||
frame.render(paint);
|
||||
}, [colorMatrixSV]);
|
||||
|
||||
const borderWidth = Math.min(viewfinderWidth, viewfinderHeight) * 0.05;
|
||||
const sideBorder = Math.min(viewfinderWidth, viewfinderHeight) * 0.06;
|
||||
const bottomBorder = viewfinderHeight * 0.18;
|
||||
@@ -96,7 +154,6 @@ export default function Viewfinder({
|
||||
<Rect x={0} y={0} width={sideBorder} height={viewfinderHeight} color="#faf9f6" />
|
||||
<Rect x={viewfinderWidth - sideBorder} y={0} width={sideBorder} height={viewfinderHeight} color="#faf9f6" />
|
||||
<Rect x={0} y={viewfinderHeight - bottomBorder} width={viewfinderWidth} height={bottomBorder} color="#faf9f6" />
|
||||
|
||||
<Rect
|
||||
x={sideBorder - 1}
|
||||
y={sideBorder - 1}
|
||||
@@ -129,51 +186,64 @@ export default function Viewfinder({
|
||||
const lonStr = formatCoordinate(gpsInfo.longitude, 'lon');
|
||||
const locationName = gpsInfo.locality || 'STREET VIEW';
|
||||
const timestampStr = new Date(gpsInfo.timestamp).toLocaleDateString('vi-VN');
|
||||
// Only tag the provenance when we know it (photos from the OS camera/library);
|
||||
// old saved sessions without a source keep the original retro caption.
|
||||
const sourceTag =
|
||||
gpsInfo.source === 'exif'
|
||||
? 'EXIF'
|
||||
: gpsInfo.source === 'mediaLibrary'
|
||||
? 'MEDIA LIB'
|
||||
: gpsInfo.source === 'device'
|
||||
? 'DEVICE GPS'
|
||||
: null;
|
||||
const captionSuffix = sourceTag ? ` | ${sourceTag}` : ' | SS 1/125 f/2.8';
|
||||
|
||||
const yOffset = viewfinderHeight - (selectedFrame === 'polaroid' ? viewfinderHeight * 0.15 : 24);
|
||||
const xOffset = selectedFrame === 'polaroid' ? viewfinderWidth * 0.08 : 16;
|
||||
|
||||
return (
|
||||
<Group>
|
||||
<SkiaText
|
||||
x={xOffset}
|
||||
y={yOffset - 32}
|
||||
text={`📍 ${locationName}`}
|
||||
font={customFont}
|
||||
color="#f59e0b"
|
||||
/>
|
||||
<SkiaText
|
||||
x={xOffset}
|
||||
y={yOffset - 16}
|
||||
text={`${latStr}, ${lonStr}`}
|
||||
font={customFont}
|
||||
color="#f59e0b"
|
||||
/>
|
||||
<SkiaText
|
||||
x={xOffset}
|
||||
y={yOffset}
|
||||
text={`${timestampStr} | SS 1/125 f/2.8`}
|
||||
font={customFont}
|
||||
color="#f59e0b"
|
||||
/>
|
||||
<SkiaText x={xOffset} y={yOffset - 32} text={`📍 ${locationName}`} font={customFont} color="#f59e0b" />
|
||||
<SkiaText x={xOffset} y={yOffset - 16} text={`${latStr}, ${lonStr}`} font={customFont} color="#f59e0b" />
|
||||
<SkiaText x={xOffset} y={yOffset} text={`${timestampStr}${captionSuffix}`} font={customFont} color="#f59e0b" />
|
||||
</Group>
|
||||
);
|
||||
};
|
||||
|
||||
const renderLiveOverlay = () => {
|
||||
return (
|
||||
<Canvas style={StyleSheet.absoluteFill}>
|
||||
const renderCameraOverlays = () => (
|
||||
<>
|
||||
<Canvas style={StyleSheet.absoluteFill} pointerEvents="none">
|
||||
{grainOpacity > 0 && noiseEffect && (
|
||||
<Group blendMode="overlay">
|
||||
<Rect x={0} y={0} width={viewfinderWidth} height={viewfinderHeight} opacity={grainOpacity}>
|
||||
<Shader source={noiseEffect} />
|
||||
</Rect>
|
||||
</Group>
|
||||
)}
|
||||
{renderFrameOverlay()}
|
||||
{renderGPSWatermark()}
|
||||
</Canvas>
|
||||
);
|
||||
};
|
||||
|
||||
<View className="absolute inset-0 flex-row justify-between pointer-events-none opacity-20">
|
||||
<View className="w-[1px] h-full bg-white ml-[33%]" />
|
||||
<View className="w-[1px] h-full bg-white mr-[33%]" />
|
||||
</View>
|
||||
<View className="absolute inset-0 flex-col justify-between pointer-events-none opacity-20">
|
||||
<View className="h-[1px] w-full bg-white mt-[33%]" />
|
||||
<View className="h-[1px] w-full bg-white mb-[33%]" />
|
||||
</View>
|
||||
|
||||
<View className="absolute bottom-2 right-2 bg-black/60 rounded-full px-2 py-0.5">
|
||||
<Text className="text-[9px] text-amber-500 font-mono">LIVE</Text>
|
||||
</View>
|
||||
</>
|
||||
);
|
||||
|
||||
if (mode === 'camera') {
|
||||
if (!cameraPermissionGranted) {
|
||||
return (
|
||||
<View
|
||||
style={{ width: viewfinderWidth, height: viewfinderHeight }}
|
||||
style={containerStyle}
|
||||
className="bg-zinc-950 border border-zinc-800 rounded-2xl flex items-center justify-center p-6"
|
||||
>
|
||||
<Text className="text-zinc-400 font-mono text-center mb-4">
|
||||
@@ -190,30 +260,40 @@ export default function Viewfinder({
|
||||
);
|
||||
}
|
||||
|
||||
if (!device) {
|
||||
return (
|
||||
<View
|
||||
style={containerStyle}
|
||||
className="bg-zinc-950 border border-zinc-800 rounded-2xl items-center justify-center p-6"
|
||||
>
|
||||
<Text className="text-zinc-500 font-mono text-center text-xs">
|
||||
Camera not available on this device
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View
|
||||
style={{ width: viewfinderWidth, height: viewfinderHeight }}
|
||||
style={containerStyle}
|
||||
className="relative overflow-hidden rounded-2xl border border-zinc-800 bg-black"
|
||||
>
|
||||
<CameraView style={StyleSheet.absoluteFill} facing="back" />
|
||||
|
||||
<View className="absolute inset-0 flex-row justify-between pointer-events-none opacity-20">
|
||||
<View className="w-[1px] h-full bg-white ml-[33%]" />
|
||||
<View className="w-[1px] h-full bg-white mr-[33%]" />
|
||||
</View>
|
||||
<View className="absolute inset-0 flex-col justify-between pointer-events-none opacity-20">
|
||||
<View className="h-[1px] w-full bg-white mt-[33%]" />
|
||||
<View className="h-[1px] w-full bg-white mb-[33%]" />
|
||||
</View>
|
||||
|
||||
{renderLiveOverlay()}
|
||||
<Camera
|
||||
ref={cameraRef}
|
||||
style={StyleSheet.absoluteFill}
|
||||
device={device}
|
||||
isActive={true}
|
||||
frameProcessor={frameProcessor}
|
||||
/>
|
||||
{renderCameraOverlays()}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
// Library mode
|
||||
return (
|
||||
<View
|
||||
style={{ width: viewfinderWidth, height: viewfinderHeight }}
|
||||
style={containerStyle}
|
||||
className="relative overflow-hidden rounded-2xl border border-zinc-800 bg-zinc-950 justify-center items-center"
|
||||
>
|
||||
{libraryImageUri && skiaImage ? (
|
||||
@@ -226,7 +306,6 @@ export default function Viewfinder({
|
||||
rect={rect(0, 0, viewfinderWidth, viewfinderHeight)}
|
||||
/>
|
||||
</Group>
|
||||
|
||||
{grainOpacity > 0 && noiseEffect && (
|
||||
<Group blendMode="overlay">
|
||||
<Rect x={0} y={0} width={viewfinderWidth} height={viewfinderHeight} opacity={grainOpacity}>
|
||||
@@ -234,7 +313,6 @@ export default function Viewfinder({
|
||||
</Rect>
|
||||
</Group>
|
||||
)}
|
||||
|
||||
{renderFrameOverlay()}
|
||||
{renderGPSWatermark()}
|
||||
</Canvas>
|
||||
|
||||
@@ -30,4 +30,5 @@ export interface GPSInfo {
|
||||
locality?: string;
|
||||
country?: string;
|
||||
timestamp: number;
|
||||
source?: 'exif' | 'mediaLibrary' | 'device'; // where the GPS data came from
|
||||
}
|
||||
|
||||
+68
-61
@@ -1,51 +1,33 @@
|
||||
// Tanner Helland's Kelvin to RGB approximation
|
||||
// Kelvin range: 1000K to 40000K (we use 2500K to 10000K)
|
||||
// Kelvin to RGB compensation for White Balance.
|
||||
// In photography, when WB is set HIGH (e.g. 10000K), the camera understands
|
||||
// the scene light is COLD and compensates with WARM tones (red/orange).
|
||||
// When WB is set LOW (e.g. 2500K), the camera compensates with COOL tones (blue).
|
||||
export function kelvinToRGB(kelvin: number): { r: number; g: number; b: number } {
|
||||
const temp = Math.max(1000, Math.min(40000, kelvin)) / 100;
|
||||
let r = 0;
|
||||
let g = 0;
|
||||
let b = 0;
|
||||
const k = Math.min(10000, Math.max(2500, kelvin));
|
||||
let r = 1.0, g = 1.0, b = 1.0;
|
||||
|
||||
// Red
|
||||
if (temp <= 66) {
|
||||
r = 255;
|
||||
if (k < 5500) {
|
||||
// 2500K → 5500K: Warm light → need blue/cyan compensation
|
||||
const factor = (5500 - k) / 3000;
|
||||
r = 1.0 - factor * 0.45;
|
||||
g = 1.0 - factor * 0.15;
|
||||
b = 1.0 + factor * 0.50;
|
||||
} else {
|
||||
r = temp - 60;
|
||||
r = 329.698727446 * Math.pow(r, -0.1332047592);
|
||||
r = Math.max(0, Math.min(255, r));
|
||||
// 5500K → 10000K: Cool light → need warm/amber compensation
|
||||
const factor = (k - 5500) / 4500;
|
||||
r = 1.0 + factor * 0.55;
|
||||
g = 1.0 + factor * 0.20;
|
||||
b = 1.0 - factor * 0.40;
|
||||
}
|
||||
|
||||
// Green
|
||||
if (temp <= 66) {
|
||||
g = temp;
|
||||
g = 99.4708025861 * Math.log(g) - 161.1195681661;
|
||||
g = Math.max(0, Math.min(255, g));
|
||||
} else {
|
||||
g = temp - 60;
|
||||
g = 288.1221695283 * Math.pow(g, -0.0755148492);
|
||||
g = Math.max(0, Math.min(255, g));
|
||||
}
|
||||
|
||||
// Blue
|
||||
if (temp >= 66) {
|
||||
b = 255;
|
||||
} else {
|
||||
if (temp <= 19) {
|
||||
b = 0;
|
||||
} else {
|
||||
b = temp - 10;
|
||||
b = 138.5177312231 * Math.log(b) - 305.0447927307;
|
||||
b = Math.max(0, Math.min(255, b));
|
||||
}
|
||||
}
|
||||
|
||||
return { r: r / 255, g: g / 255, b: b / 255 };
|
||||
return { r, g, b };
|
||||
}
|
||||
|
||||
// Generate a 4x5 ColorMatrix (array of 20 floats) based on base style and adjustments
|
||||
export function getSkiaColorMatrix(
|
||||
baseFilter: 'classic-neg' | 'provia' | 'velvia' | 'monochrome' | 'none',
|
||||
exposure: number, // -10 to +10
|
||||
contrast: number, // -10 to +10
|
||||
saturation: number, // -10 to +10
|
||||
temperature: number, // 2500 to 10000
|
||||
tint: number, // -10 to +10
|
||||
@@ -85,7 +67,7 @@ export function getSkiaColorMatrix(
|
||||
0, 0, 0, 1, 0,
|
||||
];
|
||||
} else if (baseFilter === 'monochrome') {
|
||||
// Classic black and white filter (luminance weights)
|
||||
// Acros Monochrome: ITU-R BT.709 luma — pure grayscale, zero color leakage
|
||||
const r = 0.2126;
|
||||
const g = 0.7152;
|
||||
const b = 0.0722;
|
||||
@@ -128,37 +110,62 @@ export function getSkiaColorMatrix(
|
||||
matrix = multiplyMatrices(chromeMat, matrix);
|
||||
}
|
||||
|
||||
// 5. White Balance (Kelvin Temperature and Tint)
|
||||
const rgbTemp = kelvinToRGB(temperature);
|
||||
// Tint adjustment: -10 is Green (+G), +10 is Magenta (+R, +B)
|
||||
const tintR = tint > 0 ? (tint / 10) * 0.08 : 0;
|
||||
const tintG = tint < 0 ? (-tint / 10) * 0.08 : 0;
|
||||
const tintB = tint > 0 ? (tint / 10) * 0.08 : 0;
|
||||
// 5. White Balance (Kelvin Temperature and Tint) — skip for monochrome to prevent color leakage
|
||||
if (baseFilter !== 'monochrome') {
|
||||
const rgbTemp = kelvinToRGB(temperature);
|
||||
// Tint adjustment: -10 is Green (+G), +10 is Magenta (+R, +B)
|
||||
const tintR = tint > 0 ? (tint / 10) * 0.08 : 0;
|
||||
const tintG = tint < 0 ? (-tint / 10) * 0.08 : 0;
|
||||
const tintB = tint > 0 ? (tint / 10) * 0.08 : 0;
|
||||
|
||||
const wbMat = [
|
||||
rgbTemp.r + tintR, 0, 0, 0, 0,
|
||||
0, rgbTemp.g + tintG, 0, 0, 0,
|
||||
0, 0, rgbTemp.b + tintB, 0, 0,
|
||||
0, 0, 0, 1, 0,
|
||||
];
|
||||
matrix = multiplyMatrices(wbMat, matrix);
|
||||
|
||||
// 6. Exposure adjustment (Simple offset in fifth column and slight scaling)
|
||||
if (exposure !== 0) {
|
||||
const scale = 1 + (exposure / 10) * 0.2; // scaling multiplier
|
||||
const offset = (exposure / 10) * 0.15; // translation offset
|
||||
const expMat = [
|
||||
scale, 0, 0, 0, offset,
|
||||
0, scale, 0, 0, offset,
|
||||
0, 0, scale, 0, offset,
|
||||
const wbMat = [
|
||||
rgbTemp.r + tintR, 0, 0, 0, 0,
|
||||
0, rgbTemp.g + tintG, 0, 0, 0,
|
||||
0, 0, rgbTemp.b + tintB, 0, 0,
|
||||
0, 0, 0, 1, 0,
|
||||
];
|
||||
matrix = multiplyMatrices(expMat, matrix);
|
||||
matrix = multiplyMatrices(wbMat, 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) {
|
||||
const expScale = 1 + (exposure / 10) * 0.2; // -10 → 0.8x, +10 → 1.2x
|
||||
const expOffset = (exposure / 10) * 0.15;
|
||||
const conScale = 1 + (contrast / 10) * 0.25; // -10 → 0.75x, +10 → 1.25x
|
||||
const conOffset = 0.5 * (1 - conScale);
|
||||
const adjMat = [
|
||||
expScale * conScale, 0, 0, 0, expOffset + conOffset * expScale,
|
||||
0, expScale * conScale, 0, 0, expOffset + conOffset * expScale,
|
||||
0, 0, expScale * conScale, 0, expOffset + conOffset * expScale,
|
||||
0, 0, 0, 1, 0,
|
||||
];
|
||||
matrix = multiplyMatrices(adjMat, matrix);
|
||||
}
|
||||
|
||||
return matrix;
|
||||
}
|
||||
|
||||
// Compute a tint color + opacity from the matrix for camera overlay preview
|
||||
// Applies the matrix to white (1,1,1,1) to see what color cast the filter creates
|
||||
export function computeMatrixTint(matrix: number[]): { r: number; g: number; b: number; a: number } {
|
||||
const apply = (r: number, g: number, b: number, a: number) => {
|
||||
const row = (offset: number) =>
|
||||
matrix[offset + 0] * r + matrix[offset + 1] * g + matrix[offset + 2] * b + matrix[offset + 3] * a + matrix[offset + 4];
|
||||
return { r: row(0), g: row(5), b: row(10), a: row(15) };
|
||||
};
|
||||
|
||||
// Apply matrix to neutral white to get the filter's color cast
|
||||
const white = apply(1, 1, 1, 1);
|
||||
// Clamp and compute perceived intensity to determine opacity
|
||||
const clamp = (v: number) => Math.max(0, Math.min(1, v));
|
||||
const intensity = (white.r + white.g + white.b) / 3;
|
||||
// Opacity is higher when the filter deviates from neutral
|
||||
const deviation = Math.abs(white.r - intensity) + Math.abs(white.g - intensity) + Math.abs(white.b - intensity);
|
||||
const opacity = Math.min(0.35, deviation * 2.5);
|
||||
return { r: clamp(white.r), g: clamp(white.g), b: clamp(white.b), a: opacity };
|
||||
}
|
||||
|
||||
// 4x5 Matrix multiplication utility: A * B
|
||||
function multiplyMatrices(a: number[], b: number[]): number[] {
|
||||
const result = new Array(20).fill(0);
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
// Pure EXIF GPS helpers — tolerant of the varied shapes EXIF parsers return:
|
||||
// numbers (37.7749), arrays ([d, m, s]), rational strings ("37/1", "46/1", "26964/1000")
|
||||
// and arrays of rational strings.
|
||||
|
||||
export function parseRational(value: unknown): number | null {
|
||||
if (typeof value === 'number') {
|
||||
return Number.isFinite(value) ? value : null;
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
const parts = value.trim().split('/');
|
||||
if (parts.length === 2) {
|
||||
const num = Number(parts[0]);
|
||||
const den = Number(parts[1]);
|
||||
if (Number.isFinite(num) && Number.isFinite(den) && den !== 0) return num / den;
|
||||
}
|
||||
const n = Number(value);
|
||||
return Number.isFinite(n) ? n : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function toDecimal(value: unknown, ref: string): number | null {
|
||||
// value: single decimal (number|string) OR array [deg, min, sec]
|
||||
if (Array.isArray(value)) {
|
||||
const d = parseRational(value[0]);
|
||||
const m = parseRational(value[1]);
|
||||
const s = value.length > 2 ? parseRational(value[2]) : 0;
|
||||
if (d === null || m === null || s === null || d < 0 || m < 0 || s < 0) return null;
|
||||
return d + m / 60 + s / 3600;
|
||||
}
|
||||
const n = parseRational(value);
|
||||
return n === null || n < 0 ? null : n;
|
||||
}
|
||||
|
||||
export function parseExifCoordinate(
|
||||
raw: unknown,
|
||||
ref: unknown
|
||||
): number | null {
|
||||
const absolute = toDecimal(raw, String(ref));
|
||||
if (absolute === null || absolute === 0) return null; // ignore 0,0 placeholders
|
||||
const negative = ref === 'S' || ref === 'W';
|
||||
return negative ? -absolute : absolute;
|
||||
}
|
||||
|
||||
export interface ExifLike {
|
||||
GPSLatitude?: unknown;
|
||||
GPSLatitudeRef?: unknown;
|
||||
GPSLongitude?: unknown;
|
||||
GPSLongitudeRef?: unknown;
|
||||
GPSDateStamp?: unknown;
|
||||
GPSTimeStamp?: unknown;
|
||||
DateTimeOriginal?: unknown;
|
||||
}
|
||||
|
||||
export function parseExifGPS(exif: ExifLike | null | undefined): { latitude: number; longitude: number } | null {
|
||||
if (!exif) return null;
|
||||
const lat = parseExifCoordinate(exif.GPSLatitude, exif.GPSLatitudeRef);
|
||||
const lon = parseExifCoordinate(exif.GPSLongitude, exif.GPSLongitudeRef);
|
||||
if (lat === null || lon === null) return null;
|
||||
if (Math.abs(lat) > 90 || Math.abs(lon) > 180) return null; // invalid ranges
|
||||
if (lat === 0 && lon === 0) return null;
|
||||
return { latitude: lat, longitude: lon };
|
||||
}
|
||||
|
||||
const parseTimeStamp = (value: unknown): [number, number, number] | null => {
|
||||
if (!Array.isArray(value)) return null;
|
||||
const h = parseRational(value[0]);
|
||||
const m = parseRational(value[1]);
|
||||
const s = value.length > 2 ? parseRational(value[2]) : 0;
|
||||
if (h === null || m === null || s === null) return null;
|
||||
if (h > 23 || m > 59 || s > 61) return null;
|
||||
return [h, m, s];
|
||||
};
|
||||
|
||||
// EXIF stores dates as "2023:01:02 15:04:05" — colons, not dashes.
|
||||
export function parseExifTimestamp(exif: ExifLike | null | undefined): number | null {
|
||||
if (!exif) return null;
|
||||
const raw = typeof exif.DateTimeOriginal === 'string' ? exif.DateTimeOriginal : '';
|
||||
let dateText = raw.trim();
|
||||
if (!dateText && typeof exif.GPSDateStamp === 'string') {
|
||||
const gpsDate = (exif.GPSDateStamp as string).trim();
|
||||
const gpsTime = parseTimeStamp(exif.GPSTimeStamp);
|
||||
if (gpsTime) {
|
||||
dateText = `${gpsDate} ${gpsTime[0]}:${gpsTime[1]}:${gpsTime[2]}`;
|
||||
} else if (gpsDate) {
|
||||
dateText = `${gpsDate} 00:00:00`;
|
||||
}
|
||||
}
|
||||
if (!dateText) return null;
|
||||
// Normalize "2023:1:2 15:4:5" (any of : / - separators, unpadded parts)
|
||||
// into an ISO string "2023-01-02T15:04:05" before parsing.
|
||||
const m = dateText.match(/^(\d{4})[:/-](\d{1,2})[:/-](\d{1,2})(?:[ T]+(\d{1,2}):(\d{1,2})(?::(\d{1,2}))?)?/);
|
||||
if (!m) return null;
|
||||
const two = (v: string) => v.padStart(2, '0');
|
||||
const [, y, mo, d, h = '0', mi = '0', s = '0'] = m;
|
||||
const time = Date.parse(`${y}-${two(mo)}-${two(d)}T${two(h)}:${two(mi)}:${two(s)}`);
|
||||
return Number.isFinite(time) ? time : null;
|
||||
}
|
||||
+86
-21
@@ -1,19 +1,63 @@
|
||||
import { Skia, ImageFormat, BlendMode, TileMode } from '@shopify/react-native-skia';
|
||||
import { Skia, ImageFormat, BlendMode, TileMode, type SkImage } from '@shopify/react-native-skia';
|
||||
import * as FileSystem from 'expo-file-system/legacy';
|
||||
import * as MediaLibrary from 'expo-media-library';
|
||||
import * as MediaLibrary from 'expo-media-library/legacy';
|
||||
import { Asset } from 'expo-asset';
|
||||
import { Recipe, GPSInfo, FrameId } from '../types';
|
||||
import { getSkiaColorMatrix } from './colorUtils';
|
||||
import { drawFrameOnCanvas } from './frameUtils';
|
||||
import { formatCoordinate } from './locationUtils';
|
||||
import { base64ToBytes, bytesToBase64, patchJpegDpi } from './jpegDpi';
|
||||
|
||||
export interface ExportResult {
|
||||
uri: string;
|
||||
savedToLibrary: boolean;
|
||||
}
|
||||
|
||||
export interface ExportOptions {
|
||||
// DPI to stamp into the JPEG header (metadata only — pixel size never changes).
|
||||
dpi?: number;
|
||||
// Apply a subtle Lightroom-style "Screen" output sharpen (unsharp mask) to the
|
||||
// final pixels before encoding. Pixels already export at full resolution.
|
||||
sharpen?: boolean;
|
||||
}
|
||||
|
||||
// Lightroom-style "Screen" output sharpen: subtle unsharp mask. A 3x3
|
||||
// convolution whose kernel sums to 1 (brightness preserved), amount ~0.5.
|
||||
// ponytail: fixed subtle preset tuned for full-res desktop/screen output; turn
|
||||
// into an amount/radius UI control if users ask for adjustable strength.
|
||||
function screenSharpenImage(image: SkImage, amount = 0.5): SkImage {
|
||||
const w = image.width();
|
||||
const h = image.height();
|
||||
const center = 1 + 4 * amount;
|
||||
const filter = Skia.ImageFilter.MakeMatrixConvolution(
|
||||
3, 3,
|
||||
[0, -amount, 0, -amount, center, -amount, 0, -amount, 0],
|
||||
1, 0, 1, 1, TileMode.Clamp, false, null, null
|
||||
);
|
||||
const surf = Skia.Surface.Make(w, h);
|
||||
if (!surf) return image; // fall back to unsharpened on allocation failure
|
||||
const paint = Skia.Paint();
|
||||
paint.setImageFilter(filter);
|
||||
const c = surf.getCanvas();
|
||||
c.clear(Skia.Color('transparent'));
|
||||
c.drawImage(image, 0, 0, paint);
|
||||
const sharpened = surf.makeImageSnapshot();
|
||||
if (!sharpened) return image;
|
||||
return sharpened;
|
||||
}
|
||||
|
||||
// Fixed filename: every export overwrites the previous one, so temp files
|
||||
// never accumulate in the cache directory.
|
||||
const EXPORT_FILE = `${FileSystem.cacheDirectory}camrecipe_pro_last_export.jpg`;
|
||||
|
||||
export async function processAndExportPhoto(
|
||||
sourceUri: string,
|
||||
recipe: Recipe,
|
||||
frameId: FrameId,
|
||||
useGeotag: boolean,
|
||||
gpsInfo: GPSInfo | null
|
||||
): Promise<string | null> {
|
||||
gpsInfo: GPSInfo | null,
|
||||
options?: ExportOptions
|
||||
): Promise<ExportResult | null> {
|
||||
try {
|
||||
// 1. Read source image file into Skia
|
||||
const skiaData = await Skia.Data.fromURI(sourceUri);
|
||||
@@ -47,6 +91,7 @@ export async function processAndExportPhoto(
|
||||
const matrix = getSkiaColorMatrix(
|
||||
recipe.baseFilter,
|
||||
adjustments.exposure,
|
||||
adjustments.contrast,
|
||||
adjustments.saturation,
|
||||
adjustments.temperature,
|
||||
adjustments.tint,
|
||||
@@ -55,9 +100,9 @@ export async function processAndExportPhoto(
|
||||
const colorFilter = Skia.ColorFilter.MakeMatrix(matrix);
|
||||
paint.setColorFilter(colorFilter);
|
||||
|
||||
// 4. Highlight & Shadow / Denoise / Clarity Image Filters
|
||||
// 4. Denoise / Clarity Image Filters (export-only enhancements)
|
||||
let imageFilter = null;
|
||||
|
||||
|
||||
// Denoise (Subtle blur to smooth noise)
|
||||
if (adjustments.denoise > 0) {
|
||||
const sigma = (adjustments.denoise / 10) * 0.6; // max 0.6px
|
||||
@@ -134,12 +179,12 @@ export async function processAndExportPhoto(
|
||||
if (!fontAsset.localUri) {
|
||||
await fontAsset.downloadAsync();
|
||||
}
|
||||
|
||||
|
||||
const fontData = await FileSystem.readAsStringAsync(fontAsset.localUri!, {
|
||||
encoding: FileSystem.EncodingType.Base64,
|
||||
});
|
||||
const typeface = Skia.Typeface.MakeFreeTypeFaceFromData(Skia.Data.fromBase64(fontData));
|
||||
|
||||
|
||||
if (typeface) {
|
||||
const fontSize = Math.round(width * 0.032); // 3.2% of image width
|
||||
const font = Skia.Font(typeface, fontSize);
|
||||
@@ -150,42 +195,62 @@ export async function processAndExportPhoto(
|
||||
const lonStr = formatCoordinate(gpsInfo.longitude, 'lon');
|
||||
const locationName = gpsInfo.locality || 'STREET VIEW';
|
||||
const timestampStr = new Date(gpsInfo.timestamp).toLocaleDateString('vi-VN');
|
||||
const sourceTag =
|
||||
gpsInfo.source === 'exif'
|
||||
? 'EXIF'
|
||||
: gpsInfo.source === 'mediaLibrary'
|
||||
? 'MEDIA LIB'
|
||||
: gpsInfo.source === 'device'
|
||||
? 'DEVICE GPS'
|
||||
: null;
|
||||
const captionSuffix = sourceTag ? ` | ${sourceTag}` : ' | SS 1/125 f/2.8';
|
||||
|
||||
const yOffset = height - (frameId === 'polaroid' ? height * 0.15 : fontSize * 1.5);
|
||||
const xOffset = frameId === 'polaroid' ? width * 0.08 : fontSize * 1.2;
|
||||
|
||||
canvas.drawText(`📍 ${locationName}`, xOffset, yOffset - fontSize * 2.2, textPaint, font);
|
||||
canvas.drawText(`${latStr}, ${lonStr}`, xOffset, yOffset - fontSize * 1.1, textPaint, font);
|
||||
canvas.drawText(`${timestampStr} | SS 1/125 f/2.8`, xOffset, yOffset, textPaint, font);
|
||||
canvas.drawText(`${timestampStr}${captionSuffix}`, xOffset, yOffset, textPaint, font);
|
||||
}
|
||||
}
|
||||
|
||||
// 9. Snapshot & Encode to JPEG
|
||||
const resultImage = surface.makeImageSnapshot();
|
||||
const jpegBytes = resultImage.encodeToBytes(ImageFormat.JPEG, 95);
|
||||
// 9. Snapshot & Encode to JPEG (base64 string, no SkData round-trip)
|
||||
let resultImage = surface.makeImageSnapshot();
|
||||
if (options?.sharpen) {
|
||||
resultImage = screenSharpenImage(resultImage);
|
||||
}
|
||||
let base64 = resultImage.encodeToBase64(ImageFormat.JPEG, 95);
|
||||
|
||||
if (!jpegBytes) {
|
||||
console.error('Failed to encode image to JPEG bytes');
|
||||
if (!base64) {
|
||||
console.error('Failed to encode image to JPEG');
|
||||
return null;
|
||||
}
|
||||
|
||||
// 10. Write binary bytes to temporary local file
|
||||
const tempFileUri = `${FileSystem.cacheDirectory}camrecipe_pro_export_${Date.now()}.jpg`;
|
||||
const base64Bytes = Skia.Data.fromBytes(jpegBytes).toString(); // Convert to base64
|
||||
|
||||
await FileSystem.writeAsStringAsync(tempFileUri, base64Bytes, {
|
||||
// 9b. Patch DPI metadata (JFIF density / EXIF resolution) when requested
|
||||
if (options?.dpi && options.dpi > 0) {
|
||||
const patched = patchJpegDpi(base64ToBytes(base64), options.dpi);
|
||||
base64 = bytesToBase64(patched);
|
||||
}
|
||||
|
||||
// 10. Write binary bytes to temporary local file (overwrites previous export)
|
||||
await FileSystem.writeAsStringAsync(EXPORT_FILE, base64, {
|
||||
encoding: FileSystem.EncodingType.Base64,
|
||||
});
|
||||
|
||||
// 11. Request Media Library permission & Save to device gallery
|
||||
const mediaPermission = await MediaLibrary.requestPermissionsAsync();
|
||||
if (mediaPermission.granted) {
|
||||
await MediaLibrary.saveToLibraryAsync(tempFileUri);
|
||||
try {
|
||||
await MediaLibrary.saveToLibraryAsync(EXPORT_FILE);
|
||||
return { uri: EXPORT_FILE, savedToLibrary: true };
|
||||
} catch (e) {
|
||||
console.warn('Save to gallery failed:', e);
|
||||
}
|
||||
} else {
|
||||
console.warn('Media Library permission denied. Image saved to temporary cache only.');
|
||||
}
|
||||
|
||||
return tempFileUri;
|
||||
return { uri: EXPORT_FILE, savedToLibrary: false };
|
||||
} catch (error) {
|
||||
console.error('Error during photo processing and export:', error);
|
||||
return null;
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
// Pure JPEG DPI metadata patcher.
|
||||
// Re-encoding a JPEG through Skia drops the original EXIF/JFIF headers, so the
|
||||
// DPI stamp ("72 dpi") is lost or defaulted. DPI is header metadata only — pixel
|
||||
// dimensions never change — but print pipelines read it, so this rewrites the
|
||||
// JFIF density (and EXIF XResolution/YResolution when present) to the requested
|
||||
// value. All writes are same-length, so no segment offsets move.
|
||||
//
|
||||
// If the JPEG carries neither JFIF nor EXIF, a minimal JFIF APP0 segment is
|
||||
// inserted right after SOI.
|
||||
|
||||
const B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
|
||||
|
||||
export function base64ToBytes(b64: string): Uint8Array {
|
||||
const clean = b64.replace(/\s+/g, '');
|
||||
const out: number[] = [];
|
||||
let buffer = 0;
|
||||
let bits = 0;
|
||||
for (const ch of clean) {
|
||||
if (ch === '=') break;
|
||||
const v = B64.indexOf(ch);
|
||||
if (v < 0) continue;
|
||||
buffer = (buffer << 6) | v;
|
||||
bits += 6;
|
||||
if (bits >= 8) {
|
||||
bits -= 8;
|
||||
out.push((buffer >> bits) & 0xff);
|
||||
}
|
||||
}
|
||||
return Uint8Array.from(out);
|
||||
}
|
||||
|
||||
export function bytesToBase64(bytes: Uint8Array): string {
|
||||
let out = '';
|
||||
for (let i = 0; i < bytes.length; i += 3) {
|
||||
const b0 = bytes[i];
|
||||
const b1 = i + 1 < bytes.length ? bytes[i + 1] : 0;
|
||||
const b2 = i + 2 < bytes.length ? bytes[i + 2] : 0;
|
||||
out += B64[b0 >> 2];
|
||||
out += B64[((b0 & 3) << 4) | (b1 >> 4)];
|
||||
out += i + 1 < bytes.length ? B64[((b1 & 15) << 2) | (b2 >> 6)] : '=';
|
||||
out += i + 2 < bytes.length ? B64[b2 & 63] : '=';
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function u16(bytes: Uint8Array, p: number): number {
|
||||
return (bytes[p] << 8) | bytes[p + 1];
|
||||
}
|
||||
function setU16(bytes: Uint8Array, p: number, v: number): void {
|
||||
bytes[p] = (v >> 8) & 0xff;
|
||||
bytes[p + 1] = v & 0xff;
|
||||
}
|
||||
|
||||
// Patches `bytes` in place for JFIF; returns the (possibly rebuilt) array.
|
||||
export function patchJpegDpi(input: Uint8Array, dpi: number): Uint8Array {
|
||||
if (input.length < 2 || input[0] !== 0xff || input[1] !== 0xd8) return input;
|
||||
const bytes = input.slice(); // work on a copy; insertion path rebuilds anyway
|
||||
const dpiLo = dpi & 0xff;
|
||||
const dpiHi = (dpi >> 8) & 0xff;
|
||||
|
||||
let sawJfif = false;
|
||||
let sawExif = false;
|
||||
|
||||
let i = 2;
|
||||
while (i + 1 < bytes.length && bytes[i] === 0xff) {
|
||||
while (bytes[i] === 0xff) i++;
|
||||
const marker = bytes[i];
|
||||
i++;
|
||||
if (marker === 0xd9 || marker === 0xda) break; // EOI / SOS — headers only
|
||||
if (marker === 0x01 || (marker >= 0xd0 && marker <= 0xd7)) continue;
|
||||
if (i + 2 > bytes.length) break;
|
||||
const len = u16(bytes, i);
|
||||
if (len < 2 || i + len > bytes.length) break;
|
||||
const dataStart = i + 2;
|
||||
const payloadLen = len - 2;
|
||||
|
||||
// APP0 JFIF: [FF E0] len [5x id "JFIF\0"][2 ver][1 units][2 Xd][2 Yd][1 thW][1 thH]...
|
||||
if (marker === 0xe0 && payloadLen >= 14 && bytes[dataStart] === 0x4a &&
|
||||
bytes[dataStart + 1] === 0x46 && bytes[dataStart + 2] === 0x49 &&
|
||||
bytes[dataStart + 3] === 0x46 && bytes[dataStart + 4] === 0x00) {
|
||||
sawJfif = true;
|
||||
bytes[dataStart + 7] = 1; // units = dots per inch
|
||||
bytes[dataStart + 8] = dpiHi;
|
||||
bytes[dataStart + 9] = dpiLo;
|
||||
bytes[dataStart + 10] = dpiHi;
|
||||
bytes[dataStart + 11] = dpiLo;
|
||||
}
|
||||
|
||||
// APP1 EXIF: [FF E1] len "Exif\0\0" then TIFF
|
||||
if (marker === 0xe1 && payloadLen >= 14 &&
|
||||
bytes[dataStart] === 0x45 && bytes[dataStart + 1] === 0x78 &&
|
||||
bytes[dataStart + 2] === 0x69 && bytes[dataStart + 3] === 0x66 &&
|
||||
bytes[dataStart + 4] === 0x00 && bytes[dataStart + 5] === 0x00) {
|
||||
sawExif = true;
|
||||
const tiff = dataStart + 6;
|
||||
const le = bytes[tiff] === 0x49 && bytes[tiff + 1] === 0x49;
|
||||
const be = bytes[tiff] === 0x4d && bytes[tiff + 1] === 0x4d;
|
||||
if ((le || be) && tiff + 8 <= bytes.length) {
|
||||
const read32 = (p: number): number =>
|
||||
le
|
||||
? bytes[p] | (bytes[p + 1] << 8) | (bytes[p + 2] << 16) | (bytes[p + 3] << 24)
|
||||
: ((bytes[p] << 24) | (bytes[p + 1] << 16) | (bytes[p + 2] << 8) | bytes[p + 3]) >>> 0;
|
||||
const write32 = (p: number, v: number): void => {
|
||||
if (le) {
|
||||
bytes[p] = v & 0xff; bytes[p + 1] = (v >> 8) & 0xff;
|
||||
bytes[p + 2] = (v >> 16) & 0xff; bytes[p + 3] = (v >> 24) & 0xff;
|
||||
} else {
|
||||
bytes[p] = (v >> 24) & 0xff; bytes[p + 1] = (v >> 16) & 0xff;
|
||||
bytes[p + 2] = (v >> 8) & 0xff; bytes[p + 3] = v & 0xff;
|
||||
}
|
||||
};
|
||||
// TIFF integer fields (tag/type/count/values) follow the file byte order
|
||||
const u16r = (p: number): number =>
|
||||
le ? bytes[p] | (bytes[p + 1] << 8) : (bytes[p] << 8) | bytes[p + 1];
|
||||
const u16w = (p: number, v: number): void => {
|
||||
if (le) { bytes[p] = v & 0xff; bytes[p + 1] = (v >> 8) & 0xff; }
|
||||
else { bytes[p] = (v >> 8) & 0xff; bytes[p + 1] = v & 0xff; }
|
||||
};
|
||||
const ifdOff = read32(tiff + 4);
|
||||
if (ifdOff + 2 <= bytes.length - tiff) {
|
||||
const ifd = tiff + ifdOff;
|
||||
const count = u16r(ifd);
|
||||
for (let e = 0; e < count; e++) {
|
||||
const entry = ifd + 2 + e * 12;
|
||||
if (entry + 12 > bytes.length) break;
|
||||
const tag = u16r(entry);
|
||||
const type = u16r(entry + 2);
|
||||
const cnt = read32(entry + 4);
|
||||
if ((tag === 0x011a || tag === 0x011b) && type === 5 && cnt === 1) {
|
||||
// RATIONAL: entry+8 holds offset to (num, den), relative to TIFF start
|
||||
const rat = tiff + read32(entry + 8);
|
||||
if (rat + 8 <= bytes.length) write32(rat, dpi); // keep denominator
|
||||
} else if (tag === 0x0128 && type === 3 && cnt === 1) {
|
||||
// ResolutionUnit (2 = inch): normalize to inch so the new density reads correctly
|
||||
u16w(entry + 8, 2);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
i += len;
|
||||
}
|
||||
|
||||
if (sawJfif || sawExif) return bytes;
|
||||
|
||||
// Neither segment exists — insert a minimal JFIF APP0 (density = dpi inch) after SOI.
|
||||
const app0 = new Uint8Array([
|
||||
0xff, 0xe0, 0x00, 0x10,
|
||||
0x4a, 0x46, 0x49, 0x46, 0x00, // "JFIF\0"
|
||||
0x01, 0x01, // version 1.01
|
||||
0x01, // units: inch
|
||||
dpiHi, dpiLo, dpiHi, dpiLo,
|
||||
0x00, 0x00, // no thumbnail
|
||||
]);
|
||||
const out = new Uint8Array(bytes.length + app0.length);
|
||||
out[0] = 0xff; out[1] = 0xd8;
|
||||
out.set(app0, 2);
|
||||
out.set(bytes.subarray(2), 2 + app0.length);
|
||||
return out;
|
||||
}
|
||||
@@ -50,6 +50,18 @@ export async function getCurrentGPS(): Promise<GPSInfo | null> {
|
||||
}
|
||||
}
|
||||
|
||||
export async function reverseGeocode(latitude: number, longitude: number): Promise<string | null> {
|
||||
try {
|
||||
const geo = await Location.reverseGeocodeAsync({ latitude, longitude });
|
||||
if (geo && geo.length > 0) {
|
||||
return (geo[0].subregion || geo[0].city || geo[0].region || '').toUpperCase() || null;
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Reverse geocode failed:', e);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function formatCoordinate(val: number, type: 'lat' | 'lon'): string {
|
||||
const absolute = Math.abs(val);
|
||||
const degrees = Math.floor(absolute);
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
import { Platform } from 'react-native';
|
||||
import * as MediaLibrary from 'expo-media-library';
|
||||
import { getAssetInfoAsync } from 'expo-media-library/legacy';
|
||||
import { GPSInfo } from '../types';
|
||||
import { parseExifGPS, parseExifTimestamp, ExifLike } from './exifGps';
|
||||
import { getCurrentGPS, reverseGeocode } from './locationUtils';
|
||||
|
||||
export interface PickedPhotoAsset {
|
||||
uri?: string;
|
||||
assetId?: string | null;
|
||||
exif?: ExifLike | null;
|
||||
}
|
||||
|
||||
export interface PhotoGpsResult {
|
||||
gps: GPSInfo | null;
|
||||
permissionDenied?: boolean; // true when media-library read permission was refused
|
||||
}
|
||||
|
||||
const localityFor = async (latitude: number, longitude: number): Promise<string | undefined> => {
|
||||
try {
|
||||
return (await reverseGeocode(latitude, longitude)) || undefined;
|
||||
} catch (e) {
|
||||
console.warn('Reverse geocode failed for photo GPS:', e);
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
// Android media-library ids are numeric ("12345"); ImagePicker may hand us
|
||||
// "content://media/external/images/media/12345" — reduce to the trailing number.
|
||||
// iOS ids are opaque (e.g. "PH://...") and must be passed verbatim.
|
||||
function toMediaLibraryId(assetId: string): string {
|
||||
if (Platform.OS !== 'android') return assetId;
|
||||
const m = assetId.match(/(\d+)\s*$/);
|
||||
return m ? m[1] : assetId;
|
||||
}
|
||||
|
||||
async function resolveFromMediaLibrary(
|
||||
assetId: string,
|
||||
useGeotag: boolean
|
||||
): Promise<{ gps: GPSInfo | null; permissionDenied: boolean }> {
|
||||
let perm;
|
||||
try {
|
||||
perm = await MediaLibrary.requestPermissionsAsync(false, ['photo']);
|
||||
} catch (e) {
|
||||
console.warn('MediaLibrary permission request failed:', e);
|
||||
return { gps: null, permissionDenied: true };
|
||||
}
|
||||
if (!perm.granted) {
|
||||
return { gps: null, permissionDenied: true };
|
||||
}
|
||||
try {
|
||||
const info = await getAssetInfoAsync(toMediaLibraryId(assetId));
|
||||
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;
|
||||
return {
|
||||
gps: {
|
||||
latitude,
|
||||
longitude,
|
||||
locality,
|
||||
country: undefined,
|
||||
timestamp: info.creationTime && info.creationTime > 0 ? info.creationTime : Date.now(),
|
||||
source: 'mediaLibrary',
|
||||
},
|
||||
permissionDenied: false,
|
||||
};
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('MediaLibrary getAssetInfoAsync failed:', e);
|
||||
}
|
||||
return { gps: null, permissionDenied: false };
|
||||
}
|
||||
|
||||
// Resolve GPS for a picked photo: EXIF → MediaLibrary (needs ACCESS_MEDIA_LOCATION
|
||||
// on Android 10+) → device GPS fallback.
|
||||
export async function resolvePhotoGPS(
|
||||
asset: PickedPhotoAsset,
|
||||
opts: { useGeotag: boolean; allowDeviceFallback?: boolean }
|
||||
): Promise<PhotoGpsResult> {
|
||||
const { useGeotag, allowDeviceFallback = true } = opts;
|
||||
|
||||
// Tier 1 — EXIF embedded in the file (needs no extra permission)
|
||||
const exifCoord = parseExifGPS(asset.exif);
|
||||
if (exifCoord) {
|
||||
const timestamp = parseExifTimestamp(asset.exif) ?? Date.now();
|
||||
const locality = useGeotag ? await localityFor(exifCoord.latitude, exifCoord.longitude) : undefined;
|
||||
return {
|
||||
gps: { ...exifCoord, locality, country: undefined, timestamp, source: 'exif' },
|
||||
};
|
||||
}
|
||||
|
||||
// Tier 2 — MediaLibrary metadata (Android 10+ requires ACCESS_MEDIA_LOCATION)
|
||||
if (asset.assetId) {
|
||||
const { gps, permissionDenied } = await resolveFromMediaLibrary(asset.assetId, useGeotag);
|
||||
if (gps) return { gps };
|
||||
if (permissionDenied) return { gps: null, permissionDenied: true };
|
||||
}
|
||||
|
||||
// Tier 3 — device fallback (photo has no location; stamp the device's current spot)
|
||||
if (allowDeviceFallback) {
|
||||
const deviceGps = await getCurrentGPS();
|
||||
if (deviceGps) return { gps: { ...deviceGps, source: 'device' } };
|
||||
}
|
||||
|
||||
return { gps: null };
|
||||
}
|
||||
Reference in New Issue
Block a user