Compare commits
158 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fe79d75669 | |||
| f3d26e3275 | |||
| d5e5da49b7 | |||
| 588fbf77d9 | |||
| 9f7d83276d | |||
| fd38269709 | |||
| 4863080dfe | |||
| 85b55dcf48 | |||
| 4ec4260cbd | |||
| c794283083 | |||
| cbd0178cda | |||
| c8e2b6ea5a | |||
| 1e1ead6f54 | |||
| e0526283ca | |||
| 99069aa837 | |||
| 82efc1f675 | |||
| 2bfe8f1211 | |||
| 3320513d7a | |||
| 76cf4c6d7e | |||
| d07dd69f17 | |||
| c469d9be81 | |||
| 13ad52a975 | |||
| c40b5565d3 | |||
| 12d58ff810 | |||
| 5a5c4b0d11 | |||
| 7b013fb6f8 | |||
| 8f615f6cd7 | |||
| a5e7162561 | |||
| 78420002d1 | |||
| e1ce7af76f | |||
| 78630d8a2d | |||
| 39487ffd67 | |||
| a85ebf0a81 | |||
| 27a9dfb65f | |||
| 42756ac7c3 | |||
| 06d7e8f384 | |||
| 499e61644d | |||
| 1b60ca2f22 | |||
| 97949e449d | |||
| 99405514c6 | |||
| 1f8b72ec8c | |||
| 3e9dcc40fb | |||
| 59e2719245 | |||
| a662674478 | |||
| 98b8021f3b | |||
| 93b5b664d4 | |||
| 1d84fe8a4a | |||
| 40c91d2a75 | |||
| ea9ff01a22 | |||
| 17ee01b62a | |||
| 33123d47fd | |||
| 62b9735867 | |||
| 6faef4aef7 | |||
| 1b632ba78c | |||
| d6b3ebda40 | |||
| da48ceccb5 | |||
| 0ce561cb70 | |||
| 9ddaeac260 | |||
| 6788d51fb4 | |||
| dcb74a7243 | |||
| 4131c9f401 | |||
| ba971f136c | |||
| ba7e579986 | |||
| 7ed4301055 | |||
| d66ae3dc59 | |||
| 6adf0b5933 | |||
| 7454ea1218 | |||
| 02bc3aa4da | |||
| 868ebfe6e2 | |||
| b154e50191 | |||
| 5186b2aab1 | |||
| 91109cba54 | |||
| 4bb2bda5c8 | |||
| 93f48537f8 | |||
| 299d81bade | |||
| f13fd8e0f3 | |||
| f4a5b0c683 | |||
| 614ebb170b | |||
| 423bf533f8 | |||
| 67a2a52a6f | |||
| 11ed786758 | |||
| 47e424789d | |||
| 29b75ec3d3 | |||
| 6595e5cbc1 | |||
| 2911dd57f0 | |||
| 2835f46c37 | |||
| 045f5f2048 | |||
| e529c608f0 | |||
| bba2d76957 | |||
| 4ad26b514e | |||
| 21e4932249 | |||
| e9de4203e9 | |||
| 7c7a244b84 | |||
| 6379c025c8 | |||
| 6c7d5b8f43 | |||
| 65d0850317 | |||
| d7a8e0681e | |||
| 28e18893cd | |||
| 3a8b110c1b | |||
| ff06045e02 | |||
| c0c2f8532f | |||
| 3eeef6feb9 | |||
| 144b211d35 | |||
| 2512e05c92 | |||
| 4261806dc4 | |||
| f2de481708 | |||
| 5799210949 | |||
| 568f06306a | |||
| 999f49b87f | |||
| a0ca68f176 | |||
| 55f4184e74 | |||
| ca3532023a | |||
| 3dcfd19d35 | |||
| 1fdf765d54 | |||
| a51b28f32a | |||
| 1cda62e039 | |||
| 25e4f21917 | |||
| 11635a0d33 | |||
| 71b9476901 | |||
| a72804f081 | |||
| d412f40861 | |||
| dfcf0f3a28 | |||
| a8932f52ed | |||
| f588849ac5 | |||
| bd7efce600 | |||
| 09dbc72cce | |||
| e9a9005ef2 | |||
| dca4ba0b7c | |||
| 5a94978f33 | |||
| fcad148798 | |||
| 30a2f768da | |||
| 7985f25aa8 | |||
| 125c1280fa | |||
| 56c86df1b5 | |||
| d43405a4de | |||
| a16c8734a8 | |||
| a1769e8c84 | |||
| 669224fa39 | |||
| b5a257e033 | |||
| bfdc964f65 | |||
| 7f7ceeddc6 | |||
| b581ecea2f | |||
| d2d62c6612 | |||
| 58335d8cd1 | |||
| b64d7245c9 | |||
| 588a36d05a | |||
| ea281d7974 | |||
| 43a73c541b | |||
| 7d77be5029 | |||
| 358f9b0f2f | |||
| 8eca31ce58 | |||
| 515f408c67 | |||
| 674fe9d1e2 | |||
| 200be77903 | |||
| aa6a74f229 | |||
| 96f412e269 | |||
| 0d276861e0 | |||
| 4c419f266c |
@@ -1,14 +0,0 @@
|
||||
> Why do I have a folder named ".expo" in my project?
|
||||
|
||||
The ".expo" folder is created when an Expo project is started using "expo start" command.
|
||||
|
||||
> What do the files contain?
|
||||
|
||||
- "devices.json": contains information about devices that have recently opened this project. This is used to populate the "Development sessions" list in your development builds.
|
||||
- "settings.json": contains the server configuration that is used to serve the application manifest.
|
||||
- "dev/logs/": contains structured JSONL event logs from CLI commands (e.g. start.log, export.log). These are truncated on each run.
|
||||
|
||||
> Should I commit the ".expo" folder?
|
||||
|
||||
No, you should not share the ".expo" folder. It does not contain any information that is relevant for other developers working on the project, it is specific to your machine.
|
||||
Upon project creation, the ".expo" folder is already added to your ".gitignore" file.
|
||||
@@ -1,177 +0,0 @@
|
||||
{"_e":"root:init","_t":1784254504055,"format":"v0-jsonl","version":"57.0.8"}
|
||||
{"_e":"env:mode","_t":1784254504056,"nodeEnv":"development","babelEnv":"development","mode":"development"}
|
||||
{"_e":"env:load","_t":1784254504058,"mode":"development","files":[],"env":{}}
|
||||
{"_e":"devserver:start","_t":1784254508949,"mode":"development","web":true,"baseUrl":"","asyncRoutes":false,"routerRoot":"../../../app","serverComponents":false,"serverActions":false,"serverRendering":false,"apiRoutes":false,"exporting":false}
|
||||
{"_e":"metro:config","_t":1784254509422,"serverRoot":"../../..","projectRoot":"../../..","exporting":false,"flags":{"autolinkingModuleResolution":false,"serverActions":false,"serverComponents":false,"reactCompiler":false,"optimizeGraph":false,"treeshaking":false,"logbox":false}}
|
||||
{"_e":"expo:dev-tools-plugin:load","_t":1784254509956,"plugins":[]}
|
||||
{"_e":"metro:instantiate","_t":1784254516842,"atlas":false,"workers":3,"host":"::","port":8086}
|
||||
{"_e":"metro:bundling:started","_t":1784254564715,"id":"1","platform":"android","environment":null,"entry":"index.ts"}
|
||||
{"_e":"metro:bundling:progress","_t":1784254564715,"id":"1","progress":0,"total":1,"current":0}
|
||||
{"_e":"metro:bundling:progress","_t":1784254564729,"id":"1","progress":0,"total":1,"current":0}
|
||||
{"_e":"metro:bundling:progress","_t":1784254565214,"id":"1","progress":0.010000000000000002,"total":1,"current":1}
|
||||
{"_e":"metro:bundling:progress","_t":1784254565318,"id":"1","progress":0.09,"total":9,"current":3}
|
||||
{"_e":"metro:bundling:progress","_t":1784254565418,"id":"1","progress":0.09,"total":41,"current":7}
|
||||
{"_e":"metro:bundling:progress","_t":1784254565518,"id":"1","progress":0.09,"total":46,"current":11}
|
||||
{"_e":"metro:bundling:progress","_t":1784254565577,"id":"1","progress":0.09,"total":46,"current":12}
|
||||
{"_e":"metro:bundling:progress","_t":1784254565690,"id":"1","progress":0.09,"total":147,"current":14}
|
||||
{"_e":"metro:bundling:progress","_t":1784254565794,"id":"1","progress":0.09,"total":156,"current":24}
|
||||
{"_e":"metro:bundling:progress","_t":1784254565894,"id":"1","progress":0.09,"total":157,"current":31}
|
||||
{"_e":"metro:bundling:progress","_t":1784254565998,"id":"1","progress":0.09,"total":185,"current":45}
|
||||
{"_e":"metro:bundling:progress","_t":1784254566099,"id":"1","progress":0.09,"total":197,"current":52}
|
||||
{"_e":"metro:bundling:progress","_t":1784254566211,"id":"1","progress":0.09,"total":210,"current":59}
|
||||
{"_e":"metro:bundling:progress","_t":1784254566311,"id":"1","progress":0.09,"total":223,"current":66}
|
||||
{"_e":"metro:bundling:progress","_t":1784254566419,"id":"1","progress":0.09782840730462457,"total":227,"current":71}
|
||||
{"_e":"metro:bundling:progress","_t":1784254566519,"id":"1","progress":0.11201999167013746,"total":245,"current":82}
|
||||
{"_e":"metro:bundling:progress","_t":1784254566623,"id":"1","progress":0.15123456790123457,"total":270,"current":105}
|
||||
{"_e":"metro:bundling:progress","_t":1784254566723,"id":"1","progress":0.18367346938775508,"total":287,"current":123}
|
||||
{"_e":"metro:bundling:progress","_t":1784254566823,"id":"1","progress":0.2352733413538482,"total":301,"current":146}
|
||||
{"_e":"metro:bundling:progress","_t":1784254566923,"id":"1","progress":0.259462890625,"total":320,"current":163}
|
||||
{"_e":"metro:bundling:progress","_t":1784254567024,"id":"1","progress":0.27952464836940155,"total":331,"current":175}
|
||||
{"_e":"metro:bundling:progress","_t":1784254567124,"id":"1","progress":0.33271161520688924,"total":345,"current":199}
|
||||
{"_e":"metro:bundling:progress","_t":1784254567225,"id":"1","progress":0.35676406135865596,"total":370,"current":221}
|
||||
{"_e":"metro:bundling:progress","_t":1784254567326,"id":"1","progress":0.37024645369120834,"total":401,"current":244}
|
||||
{"_e":"metro:bundling:progress","_t":1784254567428,"id":"1","progress":0.37472705039741466,"total":428,"current":262}
|
||||
{"_e":"metro:bundling:progress","_t":1784254567529,"id":"1","progress":0.3978698224852071,"total":455,"current":287}
|
||||
{"_e":"metro:bundling:progress","_t":1784254567628,"id":"1","progress":0.41077662667090886,"total":479,"current":307}
|
||||
{"_e":"metro:bundling:progress","_t":1784254567730,"id":"1","progress":0.42145226326742974,"total":496,"current":322}
|
||||
{"_e":"metro:bundling:progress","_t":1784254567834,"id":"1","progress":0.42145226326742974,"total":536,"current":333}
|
||||
{"_e":"metro:bundling:progress","_t":1784254567938,"id":"1","progress":0.42145226326742974,"total":540,"current":339}
|
||||
{"_e":"metro:bundling:progress","_t":1784254568057,"id":"1","progress":0.42145226326742974,"total":543,"current":345}
|
||||
{"_e":"metro:bundling:progress","_t":1784254568158,"id":"1","progress":0.42145226326742974,"total":556,"current":354}
|
||||
{"_e":"metro:bundling:progress","_t":1784254568257,"id":"1","progress":0.42145226326742974,"total":556,"current":356}
|
||||
{"_e":"metro:bundling:progress","_t":1784254568357,"id":"1","progress":0.42472014414228576,"total":557,"current":363}
|
||||
{"_e":"metro:bundling:progress","_t":1784254568458,"id":"1","progress":0.42472014414228576,"total":571,"current":370}
|
||||
{"_e":"metro:bundling:progress","_t":1784254568559,"id":"1","progress":0.44212055590581395,"total":573,"current":381}
|
||||
{"_e":"metro:bundling:progress","_t":1784254568663,"id":"1","progress":0.46466158211707237,"total":578,"current":394}
|
||||
{"_e":"metro:bundling:progress","_t":1784254568764,"id":"1","progress":0.48831470460153875,"total":581,"current":406}
|
||||
{"_e":"metro:bundling:progress","_t":1784254568909,"id":"1","progress":0.500828402366864,"total":585,"current":414}
|
||||
{"_e":"metro:bundling:progress","_t":1784254569015,"id":"1","progress":0.500828402366864,"total":2334,"current":415}
|
||||
{"_e":"metro:bundling:progress","_t":1784254569115,"id":"1","progress":0.500828402366864,"total":2338,"current":435}
|
||||
{"_e":"metro:bundling:progress","_t":1784254569222,"id":"1","progress":0.500828402366864,"total":2343,"current":440}
|
||||
{"_e":"metro:bundling:progress","_t":1784254569326,"id":"1","progress":0.500828402366864,"total":2346,"current":459}
|
||||
{"_e":"metro:bundling:progress","_t":1784254569467,"id":"1","progress":0.500828402366864,"total":2347,"current":467}
|
||||
{"_e":"metro:bundling:progress","_t":1784254569567,"id":"1","progress":0.500828402366864,"total":2348,"current":472}
|
||||
{"_e":"metro:bundling:progress","_t":1784254569667,"id":"1","progress":0.500828402366864,"total":2367,"current":491}
|
||||
{"_e":"metro:bundling:progress","_t":1784254569771,"id":"1","progress":0.500828402366864,"total":2428,"current":523}
|
||||
{"_e":"metro:bundling:progress","_t":1784254569871,"id":"1","progress":0.500828402366864,"total":2454,"current":542}
|
||||
{"_e":"metro:bundling:progress","_t":1784254569971,"id":"1","progress":0.500828402366864,"total":2465,"current":563}
|
||||
{"_e":"metro:bundling:progress","_t":1784254570071,"id":"1","progress":0.500828402366864,"total":2477,"current":583}
|
||||
{"_e":"metro:bundling:progress","_t":1784254570173,"id":"1","progress":0.500828402366864,"total":2478,"current":625}
|
||||
{"_e":"metro:bundling:progress","_t":1784254570273,"id":"1","progress":0.500828402366864,"total":2478,"current":677}
|
||||
{"_e":"metro:bundling:progress","_t":1784254570374,"id":"1","progress":0.500828402366864,"total":2478,"current":727}
|
||||
{"_e":"metro:bundling:progress","_t":1784254570474,"id":"1","progress":0.500828402366864,"total":2478,"current":772}
|
||||
{"_e":"metro:bundling:progress","_t":1784254570574,"id":"1","progress":0.500828402366864,"total":2478,"current":818}
|
||||
{"_e":"metro:bundling:progress","_t":1784254570674,"id":"1","progress":0.500828402366864,"total":2478,"current":863}
|
||||
{"_e":"metro:bundling:progress","_t":1784254570774,"id":"1","progress":0.500828402366864,"total":2478,"current":912}
|
||||
{"_e":"metro:bundling:progress","_t":1784254570874,"id":"1","progress":0.500828402366864,"total":2478,"current":965}
|
||||
{"_e":"metro:bundling:progress","_t":1784254570974,"id":"1","progress":0.500828402366864,"total":2478,"current":1008}
|
||||
{"_e":"metro:bundling:progress","_t":1784254571076,"id":"1","progress":0.500828402366864,"total":2478,"current":1056}
|
||||
{"_e":"metro:bundling:progress","_t":1784254571176,"id":"1","progress":0.500828402366864,"total":2478,"current":1105}
|
||||
{"_e":"metro:bundling:progress","_t":1784254571277,"id":"1","progress":0.500828402366864,"total":2478,"current":1160}
|
||||
{"_e":"metro:bundling:progress","_t":1784254571377,"id":"1","progress":0.500828402366864,"total":2478,"current":1209}
|
||||
{"_e":"metro:bundling:progress","_t":1784254571478,"id":"1","progress":0.500828402366864,"total":2478,"current":1264}
|
||||
{"_e":"metro:bundling:progress","_t":1784254571578,"id":"1","progress":0.500828402366864,"total":2478,"current":1311}
|
||||
{"_e":"metro:bundling:progress","_t":1784254571678,"id":"1","progress":0.500828402366864,"total":2478,"current":1357}
|
||||
{"_e":"metro:bundling:progress","_t":1784254571778,"id":"1","progress":0.500828402366864,"total":2478,"current":1410}
|
||||
{"_e":"metro:bundling:progress","_t":1784254571878,"id":"1","progress":0.500828402366864,"total":2478,"current":1461}
|
||||
{"_e":"metro:bundling:progress","_t":1784254571978,"id":"1","progress":0.500828402366864,"total":2478,"current":1507}
|
||||
{"_e":"metro:bundling:progress","_t":1784254572078,"id":"1","progress":0.500828402366864,"total":2478,"current":1560}
|
||||
{"_e":"metro:bundling:progress","_t":1784254572178,"id":"1","progress":0.500828402366864,"total":2478,"current":1611}
|
||||
{"_e":"metro:bundling:progress","_t":1784254572279,"id":"1","progress":0.500828402366864,"total":2478,"current":1668}
|
||||
{"_e":"metro:bundling:progress","_t":1784254572379,"id":"1","progress":0.500828402366864,"total":2478,"current":1721}
|
||||
{"_e":"metro:bundling:progress","_t":1784254572480,"id":"1","progress":0.5136689550856253,"total":2478,"current":1776}
|
||||
{"_e":"metro:bundling:progress","_t":1784254572580,"id":"1","progress":0.5394363050209071,"total":2478,"current":1820}
|
||||
{"_e":"metro:bundling:progress","_t":1784254572681,"id":"1","progress":0.5737542838642687,"total":2478,"current":1877}
|
||||
{"_e":"metro:bundling:progress","_t":1784254572781,"id":"1","progress":0.6047290409029646,"total":2478,"current":1927}
|
||||
{"_e":"metro:bundling:progress","_t":1784254572881,"id":"1","progress":0.6397417532559323,"total":2478,"current":1982}
|
||||
{"_e":"metro:bundling:progress","_t":1784254572981,"id":"1","progress":0.6737507987969679,"total":2478,"current":2034}
|
||||
{"_e":"metro:bundling:progress","_t":1784254573081,"id":"1","progress":0.7106802981654216,"total":2478,"current":2089}
|
||||
{"_e":"metro:bundling:progress","_t":1784254573181,"id":"1","progress":0.7506914764373623,"total":2478,"current":2147}
|
||||
{"_e":"metro:bundling:progress","_t":1784254573281,"id":"1","progress":0.782489458485683,"total":2478,"current":2192}
|
||||
{"_e":"metro:bundling:progress","_t":1784254573382,"id":"1","progress":0.822981380620811,"total":2478,"current":2248}
|
||||
{"_e":"metro:bundling:progress","_t":1784254573483,"id":"1","progress":0.8637444540202369,"total":2478,"current":2303}
|
||||
{"_e":"metro:bundling:progress","_t":1784254573583,"id":"1","progress":0.8846124232278917,"total":2489,"current":2341}
|
||||
{"_e":"metro:bundling:progress","_t":1784254573683,"id":"1","progress":0.8880743173619988,"total":2499,"current":2355}
|
||||
{"_e":"metro:bundling:progress","_t":1784254573786,"id":"1","progress":0.8880743173619988,"total":2589,"current":2373}
|
||||
{"_e":"metro:bundling:progress","_t":1784254573886,"id":"1","progress":0.8880743173619988,"total":2601,"current":2399}
|
||||
{"_e":"metro:bundling:progress","_t":1784254573987,"id":"1","progress":0.8880743173619988,"total":2602,"current":2410}
|
||||
{"_e":"metro:bundling:progress","_t":1784254574087,"id":"1","progress":0.8880743173619988,"total":2614,"current":2424}
|
||||
{"_e":"metro:bundling:progress","_t":1784254574188,"id":"1","progress":0.8880743173619988,"total":2617,"current":2444}
|
||||
{"_e":"metro:bundling:progress","_t":1784254574288,"id":"1","progress":0.8880743173619988,"total":2619,"current":2451}
|
||||
{"_e":"metro:bundling:progress","_t":1784254574388,"id":"1","progress":0.8880743173619988,"total":2621,"current":2455}
|
||||
{"_e":"metro:bundling:progress","_t":1784254574488,"id":"1","progress":0.8880743173619988,"total":2625,"current":2462}
|
||||
{"_e":"metro:bundling:progress","_t":1784254574588,"id":"1","progress":0.8880743173619988,"total":2631,"current":2469}
|
||||
{"_e":"metro:bundling:progress","_t":1784254574688,"id":"1","progress":0.8880743173619988,"total":2636,"current":2474}
|
||||
{"_e":"metro:bundling:progress","_t":1784254574844,"id":"1","progress":0.8880743173619988,"total":2640,"current":2481}
|
||||
{"_e":"metro:bundling:progress","_t":1784254574944,"id":"1","progress":0.8880743173619988,"total":2649,"current":2493}
|
||||
{"_e":"metro:bundling:progress","_t":1784254575044,"id":"1","progress":0.8880743173619988,"total":2700,"current":2508}
|
||||
{"_e":"metro:bundling:progress","_t":1784254575144,"id":"1","progress":0.8880743173619988,"total":2707,"current":2526}
|
||||
{"_e":"metro:bundling:progress","_t":1784254575245,"id":"1","progress":0.8880743173619988,"total":2713,"current":2534}
|
||||
{"_e":"metro:bundling:progress","_t":1784254575345,"id":"1","progress":0.8880743173619988,"total":2778,"current":2556}
|
||||
{"_e":"metro:bundling:progress","_t":1784254575447,"id":"1","progress":0.8880743173619988,"total":2786,"current":2569}
|
||||
{"_e":"metro:bundling:progress","_t":1784254575547,"id":"1","progress":0.8880743173619988,"total":2787,"current":2572}
|
||||
{"_e":"metro:bundling:progress","_t":1784254575647,"id":"1","progress":0.8880743173619988,"total":2787,"current":2574}
|
||||
{"_e":"metro:bundling:progress","_t":1784254575778,"id":"1","progress":0.8880743173619988,"total":2787,"current":2575}
|
||||
{"_e":"metro:bundling:progress","_t":1784254575878,"id":"1","progress":0.8880743173619988,"total":2789,"current":2582}
|
||||
{"_e":"metro:bundling:progress","_t":1784254575980,"id":"1","progress":0.8880743173619988,"total":2789,"current":2593}
|
||||
{"_e":"metro:bundling:progress","_t":1784254576082,"id":"1","progress":0.8880743173619988,"total":2799,"current":2612}
|
||||
{"_e":"metro:bundling:progress","_t":1784254576182,"id":"1","progress":0.8880743173619988,"total":2813,"current":2637}
|
||||
{"_e":"metro:bundling:progress","_t":1784254576282,"id":"1","progress":0.8880743173619988,"total":2824,"current":2661}
|
||||
{"_e":"metro:bundling:progress","_t":1784254576384,"id":"1","progress":0.9156393512217658,"total":2830,"current":2708}
|
||||
{"_e":"metro:bundling:progress","_t":1784254576484,"id":"1","progress":0.9156393512217658,"total":2848,"current":2724}
|
||||
{"_e":"metro:bundling:progress","_t":1784254576584,"id":"1","progress":0.9156393512217658,"total":2849,"current":2726}
|
||||
{"_e":"metro:bundling:progress","_t":1784254576684,"id":"1","progress":0.9156393512217658,"total":2868,"current":2734}
|
||||
{"_e":"metro:bundling:progress","_t":1784254576827,"id":"1","progress":0.9156393512217658,"total":2868,"current":2735}
|
||||
{"_e":"metro:bundling:progress","_t":1784254576927,"id":"1","progress":0.9156393512217658,"total":2885,"current":2738}
|
||||
{"_e":"metro:bundling:progress","_t":1784254577029,"id":"1","progress":0.9156393512217658,"total":2885,"current":2741}
|
||||
{"_e":"metro:bundling:progress","_t":1784254577129,"id":"1","progress":0.9156393512217658,"total":2900,"current":2760}
|
||||
{"_e":"metro:bundling:progress","_t":1784254577229,"id":"1","progress":0.9242757760075363,"total":2901,"current":2789}
|
||||
{"_e":"metro:bundling:progress","_t":1784254577332,"id":"1","progress":0.936287891094404,"total":2903,"current":2809}
|
||||
{"_e":"metro:bundling:progress","_t":1784254577435,"id":"1","progress":0.936287891094404,"total":2938,"current":2819}
|
||||
{"_e":"metro:bundling:progress","_t":1784254577540,"id":"1","progress":0.936287891094404,"total":2986,"current":2825}
|
||||
{"_e":"metro:bundling:progress","_t":1784254577642,"id":"1","progress":0.936287891094404,"total":3015,"current":2842}
|
||||
{"_e":"metro:bundling:progress","_t":1784254577740,"id":"1","progress":0.936287891094404,"total":3030,"current":2856}
|
||||
{"_e":"metro:bundling:progress","_t":1784254577840,"id":"1","progress":0.936287891094404,"total":3035,"current":2860}
|
||||
{"_e":"metro:bundling:progress","_t":1784254577940,"id":"1","progress":0.936287891094404,"total":3044,"current":2871}
|
||||
{"_e":"metro:bundling:progress","_t":1784254578041,"id":"1","progress":0.936287891094404,"total":3047,"current":2879}
|
||||
{"_e":"metro:bundling:progress","_t":1784254578141,"id":"1","progress":0.936287891094404,"total":3065,"current":2890}
|
||||
{"_e":"metro:bundling:progress","_t":1784254578241,"id":"1","progress":0.936287891094404,"total":3080,"current":2900}
|
||||
{"_e":"metro:bundling:progress","_t":1784254578342,"id":"1","progress":0.936287891094404,"total":3082,"current":2913}
|
||||
{"_e":"metro:bundling:progress","_t":1784254578442,"id":"1","progress":0.936287891094404,"total":3082,"current":2928}
|
||||
{"_e":"metro:bundling:progress","_t":1784254578543,"id":"1","progress":0.936287891094404,"total":3089,"current":2949}
|
||||
{"_e":"metro:bundling:progress","_t":1784254578642,"id":"1","progress":0.936287891094404,"total":3096,"current":2973}
|
||||
{"_e":"metro:bundling:progress","_t":1784254578742,"id":"1","progress":0.936287891094404,"total":3114,"current":2999}
|
||||
{"_e":"metro:bundling:progress","_t":1784254578843,"id":"1","progress":0.936287891094404,"total":3114,"current":3002}
|
||||
{"_e":"metro:bundling:progress","_t":1784254578943,"id":"1","progress":0.936287891094404,"total":3123,"current":3009}
|
||||
{"_e":"metro:bundling:progress","_t":1784254579045,"id":"1","progress":0.936287891094404,"total":3123,"current":3011}
|
||||
{"_e":"metro:bundling:progress","_t":1784254579064,"id":"1","progress":0.936287891094404,"total":3123,"current":3012}
|
||||
{"_e":"metro:bundling:progress","_t":1784254579164,"id":"1","progress":0.936287891094404,"total":3131,"current":3025}
|
||||
{"_e":"metro:bundling:progress","_t":1784254579265,"id":"1","progress":0.9365842200881371,"total":3134,"current":3033}
|
||||
{"_e":"metro:bundling:progress","_t":1784254579367,"id":"1","progress":0.9368419128824654,"total":3147,"current":3046}
|
||||
{"_e":"metro:bundling:progress","_t":1784254579468,"id":"1","progress":0.9387456790123457,"total":3150,"current":3052}
|
||||
{"_e":"metro:bundling:progress","_t":1784254579568,"id":"1","progress":0.9393609473418997,"total":3150,"current":3053}
|
||||
{"_e":"metro:bundling:progress","_t":1784254579682,"id":"1","progress":0.9412079617031998,"total":3150,"current":3056}
|
||||
{"_e":"metro:bundling:progress","_t":1784254579782,"id":"1","progress":0.9430567901234569,"total":3150,"current":3059}
|
||||
{"_e":"metro:bundling:progress","_t":1784254579883,"id":"1","progress":0.9473777777777779,"total":3150,"current":3066}
|
||||
{"_e":"metro:bundling:progress","_t":1784254579989,"id":"1","progress":0.9529773417570666,"total":3152,"current":3077}
|
||||
{"_e":"metro:bundling:progress","_t":1784254580089,"id":"1","progress":0.9549354307189066,"total":3159,"current":3087}
|
||||
{"_e":"metro:bundling:progress","_t":1784254580189,"id":"1","progress":0.9686047908988945,"total":3160,"current":3110}
|
||||
{"_e":"metro:bundling:progress","_t":1784254580292,"id":"1","progress":0.9717926782024794,"total":3168,"current":3123}
|
||||
{"_e":"metro:bundling:progress","_t":1784254580392,"id":"1","progress":0.9767778200247423,"total":3168,"current":3131}
|
||||
{"_e":"metro:bundling:progress","_t":1784254580502,"id":"1","progress":0.9768938323842832,"total":3184,"current":3147}
|
||||
{"_e":"metro:bundling:progress","_t":1784254580608,"id":"1","progress":0.9769943965134558,"total":3198,"current":3161}
|
||||
{"_e":"metro:bundling:progress","_t":1784254580709,"id":"1","progress":0.9769943965134558,"total":3220,"current":3182}
|
||||
{"_e":"metro:bundling:progress","_t":1784254580809,"id":"1","progress":0.9791352776121244,"total":3242,"current":3208}
|
||||
{"_e":"metro:bundling:progress","_t":1784254580909,"id":"1","progress":0.9791352776121244,"total":3272,"current":3232}
|
||||
{"_e":"metro:bundling:progress","_t":1784254581010,"id":"1","progress":0.9848326177487721,"total":3284,"current":3259}
|
||||
{"_e":"metro:bundling:progress","_t":1784254581111,"id":"1","progress":0.999,"total":3291,"current":3290}
|
||||
{"_e":"metro:bundling:progress","_t":1784254581211,"id":"1","progress":0.999,"total":3291,"current":3291}
|
||||
{"_e":"metro:bundling:done","_t":1784254581468,"id":"1","total":3291,"ms":16753.428873}
|
||||
{"_e":"metro:client_log","_t":1784254596888,"level":"warn","data":["SafeAreaView has been deprecated and will be removed in a future release. Please use 'react-native-safe-area-context' instead. See https://github.com/AppAndFlow/react-native-safe-area-context"]}
|
||||
{"_e":"metro:bundling:started","_t":1784254612886,"id":"3","platform":"android","environment":null,"entry":"index.ts"}
|
||||
{"_e":"metro:bundling:progress","_t":1784254612886,"id":"3","progress":0,"total":1,"current":0}
|
||||
{"_e":"metro:bundling:done","_t":1784254612993,"id":"3","total":1,"ms":107.061183}
|
||||
{"_e":"metro:client_log","_t":1784254630240,"level":"warn","data":["SafeAreaView has been deprecated and will be removed in a future release. Please use 'react-native-safe-area-context' instead. See https://github.com/AppAndFlow/react-native-safe-area-context"]}
|
||||
{"_e":"metro:client_log","_t":1784254721520,"level":"warn","data":["[expo-image-picker] `ImagePicker.MediaTypeOptions` have been deprecated. Use `ImagePicker.MediaType` or an array of `ImagePicker.MediaType` instead."]}
|
||||
{"_e":"metro:client_log","_t":1784254774528,"level":"warn","data":["[expo-image-picker] `ImagePicker.MediaTypeOptions` have been deprecated. Use `ImagePicker.MediaType` or an array of `ImagePicker.MediaType` instead."]}
|
||||
{"_e":"metro:client_log","_t":1784254928412,"level":"warn","data":["[expo-image-picker] `ImagePicker.MediaTypeOptions` have been deprecated. Use `ImagePicker.MediaType` or an array of `ImagePicker.MediaType` instead."]}
|
||||
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"devices": [
|
||||
{
|
||||
"installationId": "3ac39ffb-519e-4fcd-b686-c20d0a63c449",
|
||||
"lastUsed": 1784254612714
|
||||
}
|
||||
]
|
||||
}
|
||||
|
Before Width: | Height: | Size: 3.3 KiB |
|
Before Width: | Height: | Size: 5.8 KiB |
|
Before Width: | Height: | Size: 8.6 KiB |
|
Before Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 5.5 KiB |
|
Before Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 39 KiB |
|
Before Width: | Height: | Size: 66 KiB |
|
Before Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 2.1 KiB |
|
Before Width: | Height: | Size: 2.6 KiB |
|
Before Width: | Height: | Size: 4.9 KiB |
|
Before Width: | Height: | Size: 5.2 KiB |
|
Before Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 3.3 KiB |
|
Before Width: | Height: | Size: 6.4 KiB |
|
Before Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 29 KiB |
|
Before Width: | Height: | Size: 3.0 KiB |
|
Before Width: | Height: | Size: 5.7 KiB |
|
Before Width: | Height: | Size: 9.0 KiB |
|
Before Width: | Height: | Size: 1.4 KiB |
@@ -1 +1,5 @@
|
||||
node_modules
|
||||
.expo/
|
||||
modules/*/android/build/
|
||||
android/keystore.properties
|
||||
android/app/release.keystore
|
||||
|
||||
@@ -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)
|
||||
};
|
||||
@@ -0,0 +1,234 @@
|
||||
# 4 — FIX: HIGHLIGHT bị EXPOSURE kéo ngược (thứ tự pass màu sai)
|
||||
|
||||
Nhánh: `feat/vision-camera-v5`. Ngày: 2026-09-06.
|
||||
Nguồn đối chiếu: nhánh `recipes-web`, commit **`8e6c149`** — bản web đã sửa và đo được.
|
||||
|
||||
---
|
||||
|
||||
## 1. Hiện tượng
|
||||
|
||||
Kéo **HIGHLIGHT −10** rồi **EXPOSURE +10**: vùng sáng đã được ghì xuống lại bị đẩy
|
||||
trở lại mức cháy (1.0). Nhìn như thanh HIGHLIGHT "chết" — càng tăng EXPOSURE càng
|
||||
mất khả năng giữ highlight.
|
||||
|
||||
---
|
||||
|
||||
## 2. Nguyên nhân gốc
|
||||
|
||||
Skia áp **shader TRƯỚC colorFilter** trong cùng một `SkPaint`. Ma trận màu
|
||||
(chứa `adjustments.exposure` + gain EV) và tone shader đang nằm trên **cùng một
|
||||
paint**, nên gain exposure chạy **sau** pass tone:
|
||||
|
||||
1. Pass tone: pixel sáng 1.0 → HIGHLIGHT −10 ghì về **0.78**
|
||||
2. Ma trận exposure: **×1.2 + 0.15** → 1.086 → **kẹp về 1.0**
|
||||
|
||||
Thanh HIGHLIGHT vẫn chạy đúng — nó chỉ bị pass sau xoá sạch kết quả.
|
||||
|
||||
### 2.1 `src/utils/exportEngine.ts` (engine JS/Skia — đường export)
|
||||
|
||||
```ts
|
||||
// dòng 328
|
||||
const colorFilter = own(Skia.ColorFilter.MakeMatrix(evMatrix));
|
||||
paint.setColorFilter(colorFilter);
|
||||
|
||||
// dòng 330 — comment mô tả SAI thứ tự thực tế:
|
||||
// "3b. Tone shader — DR / Highlight / Shadow curve on top of the matrix."
|
||||
// dòng 354
|
||||
paint.setShader(toneShader);
|
||||
```
|
||||
|
||||
Comment nói tone nằm "on top of the matrix", nhưng Skia làm ngược lại.
|
||||
|
||||
### 2.2 `src/components/Viewfinder.tsx` (preview camera trực tiếp)
|
||||
|
||||
Cùng lỗi, cùng một paint:
|
||||
|
||||
```ts
|
||||
// dòng 1207
|
||||
paint.setColorFilter(baseColorFilter);
|
||||
// dòng 1303
|
||||
paint.setShader(fullShader);
|
||||
```
|
||||
|
||||
Khối HDF (dòng ~1339) còn **cố tình nhân ma trận màu lần thứ hai**:
|
||||
|
||||
```ts
|
||||
Skia.ImageFilter.MakeCompose(
|
||||
glowBlur,
|
||||
keep(Skia.ImageFilter.MakeColorFilter(baseColorFilter, null)) // "bloom lands on GRADED pixels"
|
||||
)
|
||||
```
|
||||
|
||||
→ vệt bloom cũng bị đẩy sáng sai. Sau khi sửa phải bỏ phần nhân lại này.
|
||||
|
||||
### 2.3 `modules/recipescam-export/.../RecipescamExportModule.kt` (module native Kotlin)
|
||||
|
||||
Thứ tự pass hiện tại là **tone → cinema → matrix**, tức matrix chạy **cuối** — đúng
|
||||
cùng một lỗi:
|
||||
|
||||
| Dòng | Pass |
|
||||
|---|---|
|
||||
| 423–429 | `// 2. Tone pass` → `applyTone(bmp, dr, hl, sh)` |
|
||||
| 434–438 | `// 3. Cinema seasonal grade` → `applyCinema(bmp, cinemaFlat)` |
|
||||
| 443–452 | `// 4. Color matrix via paint pass` → `ColorMatrixColorFilter` |
|
||||
| 423–426 | comment: "Engine order: the tone shader samples the base image, cinema samples the tone output, **then the color matrix filter applies on top**" |
|
||||
|
||||
---
|
||||
|
||||
## 3. Bằng chứng đo được (bản web, trước và sau khi sửa)
|
||||
|
||||
Nguồn: ảnh gradient dọc 0.98 → 0.30 (320×240 JPEG), đo p95 (vùng sáng) và trung vị
|
||||
(midtone) của ảnh render. Engine thật, không phải mô phỏng.
|
||||
|
||||
| Trường hợp | top (p95) | mid |
|
||||
|---|---|---|
|
||||
| nguồn | 1.000 | 0.502 |
|
||||
| HIGHLIGHT −10 | 0.780 | 0.518 |
|
||||
| EXPOSURE +10 | 1.000 | 0.769 |
|
||||
| HIGHLIGHT −10 + EXPOSURE +10 — **một paint (lỗi)** | **1.000** | 0.722 |
|
||||
| HIGHLIGHT −10 + EXPOSURE +10 — **hai pass (đã sửa)** | **0.780** | 0.722 |
|
||||
|
||||
Sau khi sửa: midtone vẫn sáng lên (0.502 → 0.722) nhưng vùng sáng giữ nguyên 0.780.
|
||||
|
||||
---
|
||||
|
||||
## 4. Cách sửa
|
||||
|
||||
**Nguyên tắc:** ma trận màu phải tạo ra **ảnh đã grade** trước, rồi tone/cinema shader
|
||||
mới lấy ảnh đó làm con. Đúng một câu: *matrix → tone → cinema*.
|
||||
|
||||
### 4.1 `src/utils/exportEngine.ts`
|
||||
|
||||
Thêm helper (đặt cạnh `screenSharpenImage`), rồi đổi bước 3:
|
||||
|
||||
```ts
|
||||
// Ma trận màu, áp riêng vào một ảnh mới. SkPaint chạy shader TRƯỚC colorFilter,
|
||||
// nên pixel đã grade phải tồn tại như một ảnh trước khi tone/cinema đọc nó.
|
||||
function gradeThrough(image: any, w: number, h: number, colorFilter: any): any {
|
||||
const surf = createSurface(w, h);
|
||||
if (!surf) return null;
|
||||
try {
|
||||
const p = Skia.Paint();
|
||||
p.setColorFilter(colorFilter);
|
||||
surf.getCanvas().drawImage(image, 0, 0, p);
|
||||
p.dispose();
|
||||
return surf.makeImageSnapshot() ?? null;
|
||||
} finally {
|
||||
surf.dispose();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Trong `renderPhoto`:
|
||||
|
||||
```ts
|
||||
const colorFilter = own(Skia.ColorFilter.MakeMatrix(evMatrix));
|
||||
const matrixIsIdentity = evMatrix.every((v, i) => v === (i % 6 === 0 ? 1 : 0));
|
||||
|
||||
const tone = getToneUniforms(adjustments, recipe.baseFilter);
|
||||
const cinemaOn = /* cinema && cinemaIsActive(cinema.flat) */;
|
||||
let toneShader: any = null;
|
||||
let paintShader: any = null;
|
||||
|
||||
// Ma trận phải thành ảnh riêng khi có pass phía sau đọc lại pixel.
|
||||
let gradeImage = skImage;
|
||||
const graded = (toneIsActive(tone) || cinemaOn) && !matrixIsIdentity;
|
||||
if (graded) {
|
||||
const snapshot = gradeThrough(skImage, width, height, colorFilter);
|
||||
if (snapshot) { own(snapshot); gradeImage = snapshot; }
|
||||
}
|
||||
const imageShaderOf = () =>
|
||||
own(gradeImage.makeShaderOptions(
|
||||
Skia.TileMode.Clamp, Skia.TileMode.Clamp, Skia.FilterMode.Linear, Skia.MipmapMode.None));
|
||||
|
||||
if (toneIsActive(tone)) {
|
||||
toneShader = own(toneEffect.makeShaderWithChildren(toneUniformArray(tone), [imageShaderOf()]));
|
||||
}
|
||||
if (cinemaOn) {
|
||||
const cs = own(cinemaEffect.makeShaderWithChildren(cinema.flat, [toneShader ?? imageShaderOf()]));
|
||||
if (cs) paintShader = cs;
|
||||
}
|
||||
if (!paintShader && toneShader) paintShader = toneShader;
|
||||
|
||||
// Chỉ MỘT trong hai được tô màu pixel: ảnh đã grade đã mang ma trận rồi,
|
||||
// chỉ nhánh chưa grade mới cần colorFilter.
|
||||
if (paintShader) paint.setShader(paintShader);
|
||||
else if (!graded) paint.setColorFilter(colorFilter);
|
||||
```
|
||||
|
||||
Và trong bước HDF (5b): bloom đọc pixel đã grade, nên **bỏ lần áp ma trận thứ hai**:
|
||||
|
||||
```ts
|
||||
const srcShader = paintShader ?? imageShaderOf();
|
||||
const bloomGrade = (inner?: any) =>
|
||||
graded ? undefined : own(Skia.ImageFilter.MakeColorFilter(inner ?? colorFilter, null));
|
||||
// ...
|
||||
glowPaint.setImageFilter(grade ? own(Skia.ImageFilter.MakeCompose(blur, grade)) : blur);
|
||||
```
|
||||
|
||||
Hai điều kiện đệm: khi **không có tone/cinema** hoặc **ma trận là identity** thì
|
||||
`graded === false` → không tạo surface thừa, giữ nguyên đường vẽ cũ.
|
||||
|
||||
### 4.2 `src/components/Viewfinder.tsx` (preview mỗi frame)
|
||||
|
||||
Cùng nguyên tắc, nhưng đây là đường chạy **mỗi frame** nên phải cân nhắc chi phí:
|
||||
ma trận phải được vẽ vào một surface phụ (hoặc texture trung gian) **trước**, rồi
|
||||
tone/cinema shader lấy ảnh đó làm `imageShader` con.
|
||||
|
||||
- Đổi `paint.setColorFilter(baseColorFilter)` + `paint.setShader(fullShader)` trên
|
||||
cùng một paint thành: grade trước vào texture `gradedTex`, `fullShader` dựng từ
|
||||
`gradedTex.makeShaderOptions(...)`.
|
||||
- `hasTone === false && hasCinema === false` (hoặc ma trận identity): giữ đường vẽ
|
||||
một-pass như hiện tại, đừng thêm surface.
|
||||
- Bỏ `MakeColorFilter(baseColorFilter, null)` khỏi chuỗi imageFilter của glow —
|
||||
bloom giờ đọc thẳng pixel đã grade.
|
||||
- **Đo lại fps sau khi sửa**: thêm 1 pass fullscreen/frame. Nếu tụt quá ngưỡng
|
||||
chấp nhận, phương án rẻ hơn là nhân hệ số gain exposure **bên trong SKSL của tone
|
||||
shader** (trước khi áp curve) — nhưng chỉ khi vẫn giữ được thứ tự *film matrix →
|
||||
curve*; đừng quay lại nhét exposure vào sau curve.
|
||||
|
||||
### 4.3 `RecipescamExportModule.kt` (native)
|
||||
|
||||
Đổi thứ tự các bước cho khớp: **matrix → tone → cinema**. Matrix (bước "4." hiện
|
||||
tại) phải chạy trước bước tone "2.":
|
||||
|
||||
```kotlin
|
||||
// 2. Color matrix (grade phim + exposure) — PHẢI chạy trước tone.
|
||||
val androidMatrix = FloatArray(20) { i -> if (i % 5 == 4) matrix[i] * 255f else matrix[i] }
|
||||
val out = Bitmap.createBitmap(bmp.width, bmp.height, Bitmap.Config.ARGB_8888)
|
||||
Canvas(out).drawBitmap(bmp, 0f, 0f, Paint().apply { colorFilter = ColorMatrixColorFilter(androidMatrix) })
|
||||
bmp.recycle()
|
||||
bmp = out
|
||||
|
||||
// 3. Tone (DR / highlight / shadow) trên pixel đã grade.
|
||||
if (toneActive) applyTone(bmp, toneDr.toFloat(), toneHl.toFloat(), toneSh.toFloat())
|
||||
|
||||
// 4. Cinema seasonal grade.
|
||||
if (cinemaActive) applyCinema(bmp, cinemaFlat)
|
||||
```
|
||||
|
||||
`ensureMutable(bmp)` giữ nguyên vị trí (trước tone), và sửa lại comment dòng 423–426
|
||||
— bỏ câu "then the color matrix filter applies on top".
|
||||
|
||||
---
|
||||
|
||||
## 5. Kiểm thử (bắt buộc trước khi coi là xong)
|
||||
|
||||
1. Nguồn: gradient dọc 0.98 → 0.30 (320×240 JPEG) — có headroom để thấy highlight.
|
||||
2. Export với `highlight = -10`, `exposure = +10`.
|
||||
3. Kỳ vọng: **p95 ≈ 0.78** (KHÔNG được 1.00), midtone tăng rõ so với ảnh gốc.
|
||||
4. Chạy đủ **cả ba đường** (export engine JS, preview camera, module native Kotlin)
|
||||
và so ảnh với nhau — parity gate của `PLAN.md`.
|
||||
5. Đối chiếu với bản web: `recipes-web` @ `8e6c149`, file
|
||||
`docker/frontend/src/engine/exportEngine.ts` (helper `gradeThrough`).
|
||||
|
||||
---
|
||||
|
||||
## 6. Ghi chú phụ
|
||||
|
||||
- Lỗi này **không** nằm ở công thức tone (`toneShader.ts`) hay ma trận màu
|
||||
(`colorUtils.ts`) — cả hai đều đúng. Chỉ sai **thứ tự thực thi**.
|
||||
- `HIGHLIGHT +10` không lộ lỗi vì kết quả dù sao cũng kẹp về 1.0 — chỉ nhánh âm mới
|
||||
nhìn thấy. Đừng "kiểm thử" bằng HIGHLIGHT dương.
|
||||
- Bất kỳ pass nào khác đọc lại pixel sau tone/cinema (bloom HDF, grain, vignette)
|
||||
đều phải đọc **ảnh đã grade**, và ma trận không được áp lần hai.
|
||||
@@ -0,0 +1,264 @@
|
||||
# 5 — Đốt dải thông tin ("strip") vào ảnh xuất
|
||||
|
||||
Nhánh: `feat/vision-camera-v5`. Ngày: 2026-09-06.
|
||||
Nguồn đối chiếu: nhánh `recipes-web`, commit **`b4d5d29`** — bản web đã làm và đo được.
|
||||
|
||||
---
|
||||
|
||||
## 1. Mục tiêu
|
||||
|
||||
Ảnh **xuất ra** phải mang đúng dải thông tin mà app đang hiển thị trên thẻ recipe:
|
||||
một **tagline amber** `#TAG` nằm góc trên-trái **trên chính bức ảnh**, và một **dải
|
||||
caption nền tối** treo **bên dưới** ảnh, gồm **tên recipe** (đậm, sáng) và **dòng
|
||||
kỹ thuật** `ISO · GRAIN · WARMTH/MONO` (mono, xám).
|
||||
|
||||
Đây là bản sao của khối `.lp-shot` + `.lp-frame-meta` trên landing:
|
||||
|
||||
```html
|
||||
<div class="lp-shot">
|
||||
<img … />
|
||||
<span class="lp-tagline">#ACROS_100</span> <!-- amber, góc trên-trái -->
|
||||
</div>
|
||||
<div class="lp-frame-meta">
|
||||
<b>ACROS 100</b> <!-- tên recipe -->
|
||||
<span>ISO 400 · GRAIN 4 · WARMTH +2</span> <!-- dòng kỹ thuật, mono -->
|
||||
</div>
|
||||
```
|
||||
|
||||
Thuộc tính web (để đối chiếu màu/kích thước): `docker/frontend/src/styles/landing.css`
|
||||
dòng 288–291 — tagline `color: var(--lp-amber)`, meta `font-family: var(--lp-mono)`.
|
||||
|
||||
**Ranh giới quan trọng:** dải này CHỈ có trong **file xuất**. Preview / viewfinder /
|
||||
màn hình chỉnh sửa **không** được vẽ nó — nếu không, người dùng thấy hai lần.
|
||||
|
||||
---
|
||||
|
||||
## 2. Giá trị lấy từ đâu
|
||||
|
||||
Bản web tính trong một `useMemo` duy nhất (`recipes-web` @ `b4d5d29`,
|
||||
`docker/frontend/src/App.tsx`, dòng ~658–671):
|
||||
|
||||
```ts
|
||||
const name = (recipe.name || 'RECIPE').trim();
|
||||
const slug = name.replace(/[^\p{L}\p{N}]+/gu, '_').replace(/^_+|_+$/g, '').toUpperCase();
|
||||
const grain = Math.max(0, Math.round(recipe.adjustments.grain ?? 0));
|
||||
const warmth = Math.round((recipe.adjustments.temperature - 5500) / 250);
|
||||
const tail = recipe.baseFilter === 'monochrome'
|
||||
? 'MONO'
|
||||
: `WARMTH ${warmth >= 0 ? '+' : ''}${warmth}`;
|
||||
const strip = {
|
||||
tag: `#${slug || 'RECIPE'}`, // UPPERCASE, khoảng trắng -> '_'
|
||||
title: name, // giữ nguyên hoa/thường
|
||||
meta: `ISO ${iso ?? 'AUTO'} · GRAIN ${grain} · ${tail}`,
|
||||
};
|
||||
```
|
||||
|
||||
- `·` là **U+00B7 MIDDLE DOT**, có dấu cách hai bên.
|
||||
- `iso` là ISO đọc từ **EXIF của ảnh nguồn**; không có thì in `AUTO` (xem §5).
|
||||
- `grain` lấy thẳng từ knob `adjustments.grain` (0..10), **không** cộng phần re-grain
|
||||
của NOISE REDUCTION âm — dòng kỹ thuật mô tả *công thức*, không mô tả pass xuất.
|
||||
- `warmth` là bước nhảy 250K quanh mốc 5500K, luôn kèm dấu (`+2` / `-3`).
|
||||
- `baseFilter === 'monochrome'` thì thay hẳn `WARMTH …` bằng `MONO`.
|
||||
|
||||
---
|
||||
|
||||
## 3. Hình học & màu (chính xác từng pixel)
|
||||
|
||||
Đặt `W`, `H` = chiều rộng/cao của ảnh **sau khi đã ghép frame** (ảnh export cuối cùng,
|
||||
trước encode). Mọi kích thước suy từ `W`:
|
||||
|
||||
| Đại lượng | Công thức |
|
||||
|---|---|
|
||||
| `band` | `round(W * 0.155)` |
|
||||
| `pad` | `round(W * 0.03)` |
|
||||
| `tagSize` | `round(W * 0.026)` |
|
||||
| `titleSize` | `round(W * 0.042)` |
|
||||
| `metaSize` | `round(W * 0.026)` |
|
||||
|
||||
Các bước vẽ, **theo đúng thứ tự**:
|
||||
|
||||
1. Tạo surface mới `(W, H + band)` — **ảnh cao thêm đúng `band`**. Vẽ ảnh cũ vào `(0, 0)`.
|
||||
2. **Tagline** trên ảnh, góc trên-trái, font mono:
|
||||
- bản `#000000` tại `(pad + 1, pad + tagSize + 1)` (drop shadow, để tag đọc được
|
||||
trên trời sáng),
|
||||
- bản `#f59e0b` (amber) tại `(pad, pad + tagSize)`.
|
||||
3. **Dải nền** dưới ảnh: hình chữ nhật `(0, H, W, band)` tô `#0b0b0b`.
|
||||
4. **Tên recipe** `#f2f2f2`, baseline `(pad, H + pad + titleSize)`.
|
||||
5. **Dòng kỹ thuật** `#9a9a9a`, baseline
|
||||
`(pad, H + pad + titleSize + round(metaSize * 1.7))` — tức cách baseline tên
|
||||
`round(metaSize * 1.7)`.
|
||||
|
||||
Font: **Cousine Regular** — cùng typeface app đã bundle
|
||||
(`assets/Cousine-Regular.ttf`; engine đã nạp sẵn trong `watermarkFonts.typeface`).
|
||||
|
||||
Ví dụ đo được (web): nguồn 1200×900 → file xuất **1200×1086** (`band = 186`).
|
||||
|
||||
---
|
||||
|
||||
## 4. Ba đường phải sửa (parity gate)
|
||||
|
||||
Bản web chỉ có một engine; bản app có **ba** đường vẽ, cả ba phải ra **cùng một file**:
|
||||
|
||||
### 4.1 `src/utils/exportEngine.ts` — engine Skia (bước 9b)
|
||||
|
||||
Chèn ngay **trước** dòng encode (`let bytes = resultImage.encodeToBytes(...)`, ~dòng 917),
|
||||
sau bước snapshot + sharpen (bước 9 hiện tại, ~dòng 896):
|
||||
|
||||
```ts
|
||||
const caption = options?.caption ?? null; // thêm vào ExportOptions
|
||||
if (caption) {
|
||||
const W = resultImage.width();
|
||||
const band = Math.round(W * 0.155);
|
||||
const capSurface = createSurface(W, resultImage.height() + band);
|
||||
const loaded = watermarkFonts; // cùng typeface Cousine đã nạp
|
||||
if (capSurface && loaded) {
|
||||
own(capSurface);
|
||||
const cc = capSurface.getCanvas();
|
||||
cc.drawImage(resultImage, 0, 0, own(Skia.Paint()));
|
||||
|
||||
const pad = Math.round(W * 0.03);
|
||||
const tagSize = Math.round(W * 0.026);
|
||||
const tagFont = own(Skia.Font(loaded.typeface, tagSize));
|
||||
const shadow = own(Skia.Paint()); shadow.setColor(Skia.Color('#000000'));
|
||||
const amber = own(Skia.Paint()); amber.setColor(Skia.Color('#f59e0b'));
|
||||
cc.drawText(caption.tag, pad + 1, pad + tagSize + 1, shadow, tagFont);
|
||||
cc.drawText(caption.tag, pad, pad + tagSize, amber, tagFont);
|
||||
|
||||
const bandTop = resultImage.height();
|
||||
const bandPaint = own(Skia.Paint()); bandPaint.setColor(Skia.Color('#0b0b0b'));
|
||||
cc.drawRect(Skia.XYWHRect(0, bandTop, W, band), bandPaint);
|
||||
const titleSize = Math.round(W * 0.042);
|
||||
const metaSize = Math.round(W * 0.026);
|
||||
const titleFont = own(Skia.Font(loaded.typeface, titleSize));
|
||||
const metaFont = own(Skia.Font(loaded.typeface, metaSize));
|
||||
const titlePaint = own(Skia.Paint()); titlePaint.setColor(Skia.Color('#f2f2f2'));
|
||||
const metaPaint = own(Skia.Paint()); metaPaint.setColor(Skia.Color('#9a9a9a'));
|
||||
cc.drawText(caption.title, pad, bandTop + pad + titleSize, titlePaint, titleFont);
|
||||
cc.drawText(caption.meta, pad, bandTop + pad + titleSize + Math.round(metaSize * 1.7),
|
||||
metaPaint, metaFont);
|
||||
|
||||
const composed = own(capSurface.makeImageSnapshot());
|
||||
release(owned, resultImage); // đăng ký `own`/`release` như mọi buffer khác
|
||||
resultImage = composed;
|
||||
} else if (capSurface) {
|
||||
capSurface.dispose();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`ExportOptions` thêm:
|
||||
|
||||
```ts
|
||||
// Dải file mang theo: tagline amber trên ảnh + caption band bên dưới.
|
||||
// Để trống cho preview/thumbnail — chỉ đường xuất truyền vào.
|
||||
caption?: { tag: string; title: string; meta: string } | null;
|
||||
```
|
||||
|
||||
Và tại call-site xuất (App.tsx) truyền `caption: strip` (đúng object §2) — **không**
|
||||
truyền vào đường preview.
|
||||
|
||||
### 4.2 `src/utils/nativeExport.ts` + `RecipescamExportModule.kt` — đường native
|
||||
|
||||
Đường native không đi qua Skia, nên phải **vẽ band trong Kotlin**. Thêm `caption`
|
||||
vào `NativeExportOptions` và đẩy xuống map `adjust` (cùng chỗ với `geo`, `frameId`):
|
||||
|
||||
```ts
|
||||
caption: options?.caption ?? null, // tag/title/meta — null khi không xuất file
|
||||
```
|
||||
|
||||
Trong `RecipescamExportModule.kt`, chèn **giữa bước 7 (sharpen) và bước 8 (encode)**
|
||||
(~dòng 568, sau khối `if (sharpen > 0) { … }`), vẽ lên chính `render`:
|
||||
|
||||
```kotlin
|
||||
// 7b. Strip (parity với exportEngine #9b): band mới cao hơn ảnh.
|
||||
val cap = adjust["caption"] as? Map<*, *>
|
||||
if (cap != null) {
|
||||
val tag = cap["tag"] as? String
|
||||
val title = cap["title"] as? String
|
||||
val meta = cap["meta"] as? String
|
||||
if (tag != null && title != null && meta != null) {
|
||||
val W = render.width; val H = render.height
|
||||
val band = Math.round(W * 0.155f)
|
||||
val taller = Bitmap.createBitmap(W, H + band, Bitmap.Config.ARGB_8888)
|
||||
val cc = Canvas(taller)
|
||||
cc.drawBitmap(render, 0f, 0f, null)
|
||||
val pad = Math.round(W * 0.03f)
|
||||
val tagSize = Math.round(W * 0.026f)
|
||||
val titleSize = Math.round(W * 0.042f)
|
||||
val metaSize = Math.round(W * 0.026f)
|
||||
// Cùng typeface Cousine mà stampGeo đang dùng (raw assets_cousineregular).
|
||||
val face = geoTypefaceRes(ctx, "assets_cousineregular")
|
||||
// tagline: shadow đen lệch 1px rồi amber
|
||||
val shadow = Paint(Paint.ANTI_ALIAS_FLAG).apply { color = Color.BLACK; typeface = face; textSize = tagSize.toFloat() }
|
||||
val amber = Paint(Paint.ANTI_ALIAS_FLAG).apply { color = Color.parseColor("#f59e0b"); typeface = face; textSize = tagSize.toFloat() }
|
||||
cc.drawText(tag, (pad + 1).toFloat(), (pad + tagSize + 1).toFloat(), shadow)
|
||||
cc.drawText(tag, pad.toFloat(), (pad + tagSize).toFloat(), amber)
|
||||
// dải nền tối
|
||||
cc.drawRect(0f, H.toFloat(), W.toFloat(), (H + band).toFloat(),
|
||||
Paint().apply { color = Color.parseColor("#0b0b0b") })
|
||||
val titlePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { color = Color.parseColor("#f2f2f2"); typeface = face; textSize = titleSize.toFloat() }
|
||||
val metaPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { color = Color.parseColor("#9a9a9a"); typeface = face; textSize = metaSize.toFloat() }
|
||||
cc.drawText(title, pad.toFloat(), (H + pad + titleSize).toFloat(), titlePaint)
|
||||
cc.drawText(meta, pad.toFloat(), (H + pad + titleSize + Math.round(metaSize * 1.7)).toFloat(), metaPaint)
|
||||
render.recycle()
|
||||
render = taller
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Lưu ý: band nằm **dưới frame** — RETRO POLAROID / WALL FRAME đã ghép xong trước đó
|
||||
(bước 6b), nên `H` lúc này là chiều cao tấm card/khung, không phải ảnh gốc. Web làm
|
||||
y hệt: bước 9b chạy sau khi frame đã ghép.
|
||||
|
||||
### 4.3 `src/components/Viewfinder.tsx` — preview
|
||||
|
||||
**Không** vẽ gì. Đây là điểm dễ sai nhất: dải thuộc về *file*, không thuộc về màn hình.
|
||||
|
||||
---
|
||||
|
||||
## 5. ISO từ EXIF
|
||||
|
||||
Bản web: `exifr.parse(bytes, { pick: ['ISO', 'ISOSpeedRatings'] })` →
|
||||
`number | null`; `null` in `AUTO` (`recipes-web` @ `b4d5d29`,
|
||||
`docker/frontend/src/engine/imageOps.ts`).
|
||||
|
||||
App hiện **chưa đọc tag ISO** (chỉ có GPS trong `src/utils/exifGps.ts` và ghi EXIF
|
||||
trong `exifWrite.ts`). Thêm một helper nhỏ cạnh `exifGps.ts` đọc APP1/TIFF tag
|
||||
`0x8827` (ISOSpeedRatings) từ bytes ảnh nguồn, hoặc lấy từ EXIF mà asset của ảnh đã
|
||||
nạp sẵn có. Chưa có thì trả `null` → dòng kỹ thuật in `ISO AUTO`.
|
||||
|
||||
Đừng bịa ISO từ knob: build này `ISO_SUPPORTED = false`
|
||||
(`src/components/RecipeCreateModal.tsx` dòng 74) — ISO trong dòng kỹ thuật là ISO
|
||||
**của máy ảnh lúc chụp**, không phải một lựa chọn trong app.
|
||||
|
||||
---
|
||||
|
||||
## 6. Kiểm thử (bắt buộc trước khi coi là xong)
|
||||
|
||||
1. Xuất ảnh có recipe đặt tên, grain > 0, temperature ≠ 5500.
|
||||
2. Kỳ vọng hình học: file cao hơn ảnh gốc đúng `round(W * 0.155)`; ví dụ nguồn
|
||||
1200×900 → **1200×1086**.
|
||||
3. Đo pixel: hàng trong dải nền tối (độ sáng < 0.2); **tagline amber** có pixel
|
||||
`#f59e0b` (đếm > 20 pixel) ở góc trên-trái *trên ảnh*; chữ `#f2f2f2` /
|
||||
`#9a9a9a` nằm trong dải dưới.
|
||||
4. Chạy **cả ba đường** — engine Skia, native Kotlin, và (đối chiếu) web @ `b4d5d29`.
|
||||
Band phải trùng khít giữa ba đường: cùng chiều cao, cùng màu, cùng baseline.
|
||||
5. Preview / viewfinder: khẳng định **không** xuất hiện dải (không có pixel amber
|
||||
`#f59e0b` ngoài watermark vốn có).
|
||||
6. Đối chiếu web: `docker/frontend/src/engine/exportEngine.ts` bước **9b**
|
||||
(`recipes-web` @ `b4d5d29`).
|
||||
|
||||
---
|
||||
|
||||
## 7. Ghi chú phụ
|
||||
|
||||
- Band đặt **sau** frame và **trước** encode — nếu vẽ trước frame, khung sẽ đè mất
|
||||
chữ; nếu vẽ sau encode, file không còn pixel để vẽ.
|
||||
- `band` tính từ **chiều rộng ảnh cuối**, không phải chiều rộng màn hình: ảnh 12MP
|
||||
và ảnh 1200px ra tỉ lệ chữ khác nhau, đúng như mong đợi.
|
||||
- Khi ảnh đã có watermark người dùng / GPS stamp, dải vẫn treo ngoài ảnh — không
|
||||
chồng lên các lớp đó.
|
||||
- Nếu sau này app có "lưu vào bộ sưu tập" tách khỏi "tải xuống": bản web cố ý lưu
|
||||
bản **sạch** (không đốt dải) cho lưới cộng đồng và chỉ đốt dải vào file tải xuống,
|
||||
để khung của lưới tự vẽ tagline từ nhãn đã lưu — tránh tag hai lần. Nếu app không
|
||||
có lưới cộng đồng thì cứ đốt thẳng vào file xuất.
|
||||
@@ -0,0 +1,191 @@
|
||||
# 6 — CREATE RECIPES: gấp nhóm + thêm EXPOSURE / EV / WHITE / BLACK
|
||||
|
||||
Nhánh: `feat/vision-camera-v5`. Ngày: 2026-09-06.
|
||||
Nguồn đối chiếu: nhánh `recipes-web`, commit **`f17cb08`** ("web: fold the create
|
||||
form's groups and add the white/black point rows") — bản web đã làm, đã đo và
|
||||
đang chạy ở `http://localhost:8090/app`. Commit `cf4b01d` là việc đổi màu tên
|
||||
recipe đang chọn trong FAVORITED; bản app **đã có sẵn** (`AdjustmentPanel.tsx`,
|
||||
tab `favorites`: `active: r.id === currentRecipeId`) nên không cần làm lại.
|
||||
|
||||
Đây là ghi chú yêu cầu, **chưa implement trên app**. Web đã xong và là bản mẫu
|
||||
để đối chiếu từng dòng.
|
||||
|
||||
---
|
||||
|
||||
## 1. Mục tiêu
|
||||
|
||||
Form CREATE RECIPES đã dài thành một cuộn dọc. Hai việc:
|
||||
|
||||
1. **Gấp được** sáu nhóm phân loại: SIMULATION, DYNAMIC RANGE, GRAIN EFFECT,
|
||||
COLOR CHROME EFFECT, COLOR CHROME EFFECT BLUE, WHITE BALANCE.
|
||||
2. **Thêm bốn ô số**: EXPOSURE, EV, WHITE, BLACK.
|
||||
|
||||
Thứ tự hàng giữ nguyên như điện thoại đang có, chỉ chèn thêm — trừ
|
||||
`EXPOSURE COMP.` đổi tên thành `EV` (cùng một tham số `exposureCompensation`,
|
||||
chỉ khác nhãn; web đã đổi để hai chữ EXPOSURE/EV không lẫn nhau).
|
||||
|
||||
---
|
||||
|
||||
## 2. Việc 1 — gấp nhóm (thuần UI, chỉ `RecipeCreateModal.tsx`)
|
||||
|
||||
Web dùng `<details>`/`<summary>` gốc của trình duyệt: không state, không
|
||||
thư viện, trình duyệt tự giữ cờ mở/đóng. App dùng React Native nên phải tự làm:
|
||||
một `useState<Record<string, boolean>>` cho sáu nhóm, nhãn nhóm bấm được, mũi
|
||||
tên xoay 90° khi mở, và phần thân chỉ render khi nhóm đang mở.
|
||||
|
||||
Ranh giới từng nhóm trong `RecipeCreateModal.tsx` (số dòng theo bản hiện tại):
|
||||
|
||||
| Nhóm | Hàng nằm trong |
|
||||
| --- | --- |
|
||||
| SIMULATION | dải chip `FILM_SIMS` |
|
||||
| DYNAMIC RANGE | dải chip `DR_OPTS` |
|
||||
| GRAIN EFFECT | dải chip `GRAIN_OPTS` |
|
||||
| COLOR CHROME EFFECT | dải chip `CC_OPTS` |
|
||||
| COLOR CHROME EFFECT BLUE | dải chip `CC_OPTS` |
|
||||
| WHITE BALANCE | dải chip `WB_PRESETS` **+ hai ô WB SHIFT RED/BLUE** |
|
||||
|
||||
Ngoài nhóm (luôn hiện): NAME, lưới số (`fieldSets`), hàng ISO, SAVE/EXPORT.
|
||||
Khối WB SHIFT đi cùng WHITE BALANCE — về mặt nhiếp ảnh nó là một phần của WB.
|
||||
|
||||
Mặc định **mở cả sáu** để form không đổi hành vi cho tới khi người dùng tự gấp.
|
||||
|
||||
CSS web đã dùng (đối chiếu nếu muốn giống hệt):
|
||||
|
||||
```css
|
||||
.create-sec { display: flex; flex-direction: column; gap: 6px; }
|
||||
.create-sec > summary { display: flex; align-items: center; gap: 6px; cursor: pointer; list-style: none; user-select: none; }
|
||||
.create-sec > summary::-webkit-details-marker { display: none; }
|
||||
.create-sec > summary::before { content: '▸'; font-size: 9px; transition: transform 0.15s ease; }
|
||||
.create-sec[open] > summary::before { transform: rotate(90deg); }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Việc 2 — bốn ô số
|
||||
|
||||
`fieldSets` hiện tại:
|
||||
|
||||
```ts
|
||||
{ label: 'HIGHLIGHT', value: fHigh, set: setFHigh },
|
||||
{ label: 'SHADOW', value: fShadow, set: setFShadow },
|
||||
{ label: 'COLOR', value: fColor, set: setFColor },
|
||||
{ label: 'NOISE RED.', value: fNr, set: setFNr, ph: '-10..10' },
|
||||
{ label: 'SHARPENING', value: fSharp, set: setFSharp, ph: '-10..10' },
|
||||
{ label: 'CLARITY', value: fClarity, set: setFClarity },
|
||||
{ label: 'EXPOSURE COMP.', value: fEv, set: setFEv, ph: 'EV' },
|
||||
```
|
||||
|
||||
Web sau khi sửa (đúng thứ tự này):
|
||||
|
||||
```ts
|
||||
{ label: 'HIGHLIGHT', value: fHigh, set: setFHigh },
|
||||
{ label: 'SHADOW', value: fShadow, set: setFShadow },
|
||||
{ label: 'WHITE', value: fWhites, set: setFWhites },
|
||||
{ label: 'BLACK', value: fBlacks, set: setFBlacks },
|
||||
{ label: 'COLOR', value: fColor, set: setFColor },
|
||||
{ label: 'NOISE RED.', value: fNr, set: setFNr, ph: '-10..10' },
|
||||
{ label: 'SHARPENING', value: fSharp, set: setFSharp, ph: '-10..10' },
|
||||
{ label: 'CLARITY', value: fClarity, set: setFClarity },
|
||||
{ label: 'EXPOSURE', value: fExposure, set: setFExposure },
|
||||
{ label: 'EV', value: fEv, set: setFEv, ph: '±3 EV' },
|
||||
```
|
||||
|
||||
Thêm ba state (`fWhites`, `fBlacks`, `fExposure`) song song với các `f*` đang
|
||||
có, seed từ `seedAdj`/`valuesOf`, và **bắt buộc** thêm vào `handleSave` — thiếu
|
||||
một dòng là ô vừa nhập bị `...seedAdj` ghi đè và recipe lưu ra khác màn hình:
|
||||
|
||||
```ts
|
||||
whites: int(fWhites, simAdj.whites ?? 0, -10, 10),
|
||||
blacks: int(fBlacks, simAdj.blacks ?? 0, -10, 10),
|
||||
exposure: int(fExposure, simAdj.exposure, -10, 10),
|
||||
```
|
||||
|
||||
`EXPOSURE` là `adjustments.exposure` (-10..10, độ lợi ma trận) — đã có sẵn trong
|
||||
model và trong tab LIGHT, chỉ thiếu hàng trong CREATE. `EV` là
|
||||
`exposureCompensation` (-3..+3 EV) như hàng `EXPOSURE COMP.` cũ.
|
||||
|
||||
---
|
||||
|
||||
## 4. `WHITE` / `BLACK` là tham số MỚI — phải thêm cả model lẫn shader
|
||||
|
||||
Không có `whites`/`blacks` ở đâu trong app (đã grep cả hai nhánh). Nếu chỉ vẽ ô
|
||||
nhập mà không đụng engine thì đó là **ô chết**: nhập xong không ảnh nào đổi.
|
||||
|
||||
### 4.1 `src/types/index.ts` — `ColorAdjustments`
|
||||
|
||||
```ts
|
||||
highlight: number; // -10 to +10 (- pulls highlights down / roll, + brightens)
|
||||
shadow: number; // -10 to +10 (+ lifts shadows, - deepens)
|
||||
whites?: number; // -10 to +10 (white point: the very top of the tone curve)
|
||||
blacks?: number; // -10 to +10 (black point: the very toe of the tone curve)
|
||||
```
|
||||
|
||||
Để **optional** (`?`) — recipe cũ trong máy không có hai khóa này vẫn đọc được.
|
||||
|
||||
### 4.2 `src/utils/toneShader.ts` — `TONE_SKSL`
|
||||
|
||||
Khai báo uniform ngay sau `sh` (thứ tự này phải khớp `toneUniformArray`):
|
||||
|
||||
```glsl
|
||||
uniform float wh;
|
||||
uniform float bl;
|
||||
```
|
||||
|
||||
Trong `main`, chèn **sau** hai dòng Dynamic Range và **trước** `o = clamp(o, …)`:
|
||||
|
||||
```glsl
|
||||
// White/black point. The HL/SH knees above already spend the slope budget
|
||||
// (each just under 1.0), so these two use cubic end-weights instead of
|
||||
// another smoothstep: only the last/first stretch of the ramp moves, and the
|
||||
// derivative stays >= 1 - 3*0.18 = 0.46 for every combination, so the curve
|
||||
// can never invert. + lifts that end (faded blacks / brighter whites),
|
||||
// - digs it in (true black / rolled white).
|
||||
o += bl * 0.18 * (1.0 - t) * (1.0 - t) * (1.0 - t);
|
||||
o += wh * 0.18 * t * t * t;
|
||||
```
|
||||
|
||||
Vì sao **không** dùng thêm một cặp `smoothstep` như HL/SH: hai knee 0.65..1.00
|
||||
và 0.00..0.55 đã tiêu gần hết "ngân sách độ dốc" (mỗi cái ~0.94/1.0); thêm một
|
||||
knee nữa là đẩy tổng độ dốc qua 1.0 và đường cong **đảo** (điểm sáng hơn ra tối
|
||||
hơn). Cubic end-weight chỉ chạm ~đoạn cuối của dải, đạo hàm luôn ≥ 0.46.
|
||||
|
||||
### 4.3 Bốn chỗ còn lại trong cùng file
|
||||
|
||||
```ts
|
||||
export interface ToneUniforms {
|
||||
dr: number; hl: number; sh: number;
|
||||
wh: number; // -1..1 (adjustments.whites / 10 — white point)
|
||||
bl: number; // -1..1 (adjustments.blacks / 10 — black point)
|
||||
vib: number; …
|
||||
}
|
||||
```
|
||||
|
||||
```ts
|
||||
const wh = Math.max(-1, Math.min(1, (adj.whites ?? 0) / 10));
|
||||
const bl = Math.max(-1, Math.min(1, (adj.blacks ?? 0) / 10));
|
||||
```
|
||||
|
||||
`getToneUniforms` trả thêm `wh, bl`; `toneIsActive` thêm `u.wh !== 0 || u.bl !== 0`
|
||||
(không thêm thì pass tone bị bỏ qua và hai ô không có tác dụng); và
|
||||
`toneUniformArray` — **thứ tự phần tử phải trùng khai báo GLSL**:
|
||||
|
||||
```ts
|
||||
return [u.dr, u.hl, u.sh, u.wh, u.bl, u.vib,
|
||||
u.shT[0], u.shT[1], u.shT[2], u.hlT[0], u.hlT[1], u.hlT[2], u.cc, u.ccb];
|
||||
```
|
||||
|
||||
`dr`/`hl`/`sh` hai nhánh giống nhau; chỉ 4 khối ở mục 4.2–4.3 là mới.
|
||||
|
||||
---
|
||||
|
||||
## 5. Kiểm thử (đo được, không đoán)
|
||||
|
||||
Web đã đo bằng ảnh thật (`/tmp/w0/p2.jpg`): `WHITE +10` nâng độ chói decile trên
|
||||
cùng, `BLACK +10` nâng decile dưới cùng và **không** đụng đỉnh, `BLACK -10` hạ
|
||||
decile dưới. Vòng round-trip `.recipe` (XML xor16-v1) giữ nguyên
|
||||
`whites = 4`, `blacks = -3`, `exposureCompensation = 2` qua chính importer của
|
||||
điện thoại.
|
||||
|
||||
Bản app cần đo lại trên **cả ba đường vẽ** (engine Skia xuất file, native export,
|
||||
preview viewfinder) như ghi chú 5 đã nêu: cùng một cặp WHITE/BLACK phải ra cùng
|
||||
một ảnh.
|
||||
@@ -0,0 +1,158 @@
|
||||
# 7 — SCAN QR: quét mã trên trang web để import recipe vào app
|
||||
|
||||
Nhánh: `feat/vision-camera-v5`. Ngày: 2026-09-06.
|
||||
Nguồn đối chiếu: nhánh `recipes-web`, commit **`8a889db`** ("web: the QR card
|
||||
hands out the look that made the photo") — bản web đã làm, đã đo, đang chạy ở
|
||||
`http://localhost:8090`.
|
||||
|
||||
Đây là ghi chú cho phần mềm Android **và đã implement trên nhánh này**. Khác
|
||||
các ghi chú 1–6 (chỉ nêu yêu cầu), ghi chú này ghi lại cả hai nửa: web đã mở ra
|
||||
cái gì, và app đọc cái đó bằng cách nào.
|
||||
|
||||
---
|
||||
|
||||
## 1. Web đã mở ra cái gì (phía `recipes-web`)
|
||||
|
||||
Trang admin có thêm **checkbox thứ tư `qr`** cạnh `strip / tester / creator`.
|
||||
Tick `qr` cho một ảnh nghĩa là: ảnh đó xuất hiện ở thẻ QR trên landing page.
|
||||
|
||||
Ảnh được tick `qr` phải có **preset** (`photos.recipe`, cột JSON). Khi có,
|
||||
thẻ QR trên landing trỏ tới một route công khai mới của backend:
|
||||
|
||||
```
|
||||
GET /api/photos/:id/preset.recipe
|
||||
```
|
||||
|
||||
| Trường hợp | Trả về |
|
||||
| --- | --- |
|
||||
| `id` không phải số, không có hàng, `recipe IS NULL`, hoặc ảnh không tick `qr` | `404` |
|
||||
| Hợp lệ | `200`, `content-type: application/xml; charset=utf-8`, `x-content-type-options: nosniff`, `content-disposition: attachment; filename="recipescam-<id>.recipe"` |
|
||||
|
||||
Nội dung file là **đúng định dạng `.recipe` mà app đã xuất/nhập**: XML bọc
|
||||
payload xor16-v1, sinh tại `docker/backend/src/recipeFile.ts` (bản sao của
|
||||
`shared/utils/recipeShare.ts`). Salt mới mỗi lần tải — hai lần quét cùng một mã
|
||||
ra hai file khác byte nhưng cùng recipe. `test/security.mjs` khoá hình dạng
|
||||
envelope này (+12 check, backend 134/134).
|
||||
|
||||
Vì vậy **mã QR chỉ chứa URL**, không chứa recipe. Vài chục byte, không phải
|
||||
vài KB base64 — camera chỉ cần bắt được một liên kết ngắn.
|
||||
|
||||
Nhãn i18n liên quan (đã có trên web): `adm.pickQr` = `QR card` / `Thẻ QR`,
|
||||
`adm.noPreset` = `no look to hand out` / `không có look để chia sẻ`.
|
||||
|
||||
---
|
||||
|
||||
## 2. Vì sao KHÔNG dùng vision-camera để quét
|
||||
|
||||
Đường thẳng nhất là quét QR bằng chính session camera của viewfinder. Không
|
||||
được: **vision-camera v5 trên Android không có object output.**
|
||||
|
||||
```text
|
||||
modules/.../HybridCameraFactory.kt:112
|
||||
createObjectOutput → throw "CameraObjectOutput is not available on Android!"
|
||||
```
|
||||
|
||||
`useObjectOutput` là API iOS-only. Barcode/QR trên Android phải đi đường khác,
|
||||
nên app dùng **`expo-camera`** — nó bọc chính barcode scanner của Android.
|
||||
|
||||
```bash
|
||||
npx expo install expo-camera # ~57.0.5
|
||||
```
|
||||
|
||||
`app.json` thêm plugin trước `expo-sharing`:
|
||||
|
||||
```json
|
||||
["expo-camera", { "cameraPermission": "Allow RecipesCam to scan a recipe QR code." }]
|
||||
```
|
||||
|
||||
**Hệ quả bắt buộc: phải build lại dev client / APK.** `expo-camera` là native
|
||||
module mới; bundle JS cũ không có nó.
|
||||
|
||||
App giờ có **hai thư viện camera**. CameraX không cho hai client cùng giữ một
|
||||
ống kính, nên khi sheet quét mở thì viewfinder phải nhả camera ra — xem mục 4.
|
||||
|
||||
### 2.1 HTTP thuần (cleartext) — chỉ chạy ở debug
|
||||
|
||||
Route preset hiện phục vụ qua **HTTP thuần** (`http://<host>:8090/...`). Android
|
||||
9+ chặn cleartext mặc định, nhưng manifest debug đã bật sẵn:
|
||||
|
||||
```xml
|
||||
<!-- android/app/src/debug/AndroidManifest.xml -->
|
||||
<application android:usesCleartextTraffic="true" ... />
|
||||
```
|
||||
|
||||
`android/app/src/main/AndroidManifest.xml` **không** có dòng đó. Nghĩa là:
|
||||
|
||||
| Bản build | Quét thẻ QR |
|
||||
| --- | --- |
|
||||
| dev client / APK debug | chạy — cleartext được bật |
|
||||
| APK release | `fetch` bị chặn, `Import failed` |
|
||||
|
||||
Nếu bản release cũng phải quét được thì **một trong hai**: phục vụ landing qua
|
||||
HTTPS (tốt hơn — khi đó không phải đụng gì), hoặc bật cleartext cho release bằng
|
||||
`expo-build-properties` (`android.usesCleartextTraffic: true`) — plugin này chưa
|
||||
cài, nên chỉ thêm khi thật sự cần.
|
||||
|
||||
---
|
||||
|
||||
## 3. `src/components/QrScanModal.tsx` (mới)
|
||||
|
||||
Sheet quét: `Modal` + `CameraView` (`facing="back"`, `barcodeScannerSettings` chỉ
|
||||
`['qr']`), xin quyền bằng `useCameraPermissions()` khi mở, và một **chốt
|
||||
`takenRef`** — scanner báo mã ở mọi frame còn giữ mã, chốt chặn lần đầu để
|
||||
không import mười lần. Mở lại sheet thì chốt reset.
|
||||
|
||||
Props: `{ visible, onCancel, onScanned }`. Khung ngắm màu hổ phách + dòng
|
||||
`POINT AT A RECIPE QR` + nút X đóng. Khung ngắm `pointerEvents="none"` để cú
|
||||
chạm rơi xuống camera bên dưới.
|
||||
|
||||
---
|
||||
|
||||
## 4. Nối vào App
|
||||
|
||||
| File | Thay đổi |
|
||||
| --- | --- |
|
||||
| `App.tsx` | state `scanVisible`; `storeImportedRecipe(xml)` tách ra dùng chung với IMPORT; `handleScanRecipe(value)`; truyền `onScanRecipe` xuống panel; render `<QrScanModal>`; truyền `paused={scanVisible}` xuống viewfinder |
|
||||
| `src/components/AdjustmentPanel.tsx` | prop `onScanRecipe: () => void`; chip `SCAN QR` sau chip `IMPORT` trong tab `recipes` |
|
||||
| `src/components/Viewfinder.tsx` | prop `paused?: boolean` (mặc định `false`); `isActive={appActive && !ultraWideOn && !paused}`, `active={appActive && ultraWideOn && !paused}` |
|
||||
| `app.json` | plugin `expo-camera` + câu xin quyền |
|
||||
| `package.json` | `expo-camera ~57.0.5` |
|
||||
|
||||
`handleScanRecipe` **đóng sheet trước**, rồi mới xử lý — thất bại là một hộp
|
||||
thoại, không phải một scanner còn chạy. Cổng `liteSaveBlocked` /
|
||||
`promptRecipeLimit()` đứng trước, giống đường IMPORT.
|
||||
|
||||
Liên kết phải đúng route preset, không thì từ chối kèm lý do:
|
||||
|
||||
```ts
|
||||
const link = /^https?:\/\/\S+\/api\/photos\/\d+\/preset\.recipe$/.exec(value.trim());
|
||||
if (!link) throw new Error('That code is not a RecipesCam recipe link.');
|
||||
const res = await fetch(link[0]);
|
||||
if (!res.ok) throw new Error(`The recipe link answered ${res.status}.`);
|
||||
await storeImportedRecipe(await res.text());
|
||||
```
|
||||
|
||||
Nhờ vậy mã QR của wifi, link shop, link app store… đều bị chặn bằng câu
|
||||
`That code is not a RecipesCam recipe link.` chứ không im lặng nuốt.
|
||||
|
||||
`storeImportedRecipe` chính là thân cũ của `handleImportRecipe`: dynamic-import
|
||||
`importRecipeXml` → `saveCustomRecipe` → `getAllRecipes` → `applyRecipe` →
|
||||
Haptics success → `Alert.alert('Recipe Imported', …)`. Hai đường (chọn file và
|
||||
quét mã) giờ dùng chung một chỗ lưu, nên recipe quét về nằm cùng danh sách
|
||||
custom như recipe import bằng file.
|
||||
|
||||
---
|
||||
|
||||
## 5. Kiểm thử
|
||||
|
||||
- `node --stack-size=12000 node_modules/typescript/bin/tsc --noEmit` → đúng 15
|
||||
lỗi có sẵn (`Viewfinder.tsx`, `skiaImage.ts`), **không lỗi nào ở
|
||||
`App.tsx` / `QrScanModal.tsx` / `AdjustmentPanel.tsx`.
|
||||
(`npx tsc --noEmit` trên cây Android crash `RangeError: Maximum call stack size exceeded` — lỗi có sẵn, phải truyền `--stack-size`.)
|
||||
- Phía web: `preset-roundtrip.cjs` 11/0 — file backend sinh ra import được bằng
|
||||
chính parser của app (tiếng Việt có dấu, `classic-vivid`, adjustments,
|
||||
`frameId`, `useGeotag`), và envelope lạ bị từ chối.
|
||||
- **Còn phải đo trên máy thật** sau khi build lại: quét thẻ QR trên landing →
|
||||
recipe xuất hiện trong PRESETS và look đổi; quét một mã không phải link
|
||||
RecipesCam → hộp thoại từ chối; mở sheet trong khi viewfinder đang chạy →
|
||||
không có lỗi CameraX "camera already in use".
|
||||
@@ -0,0 +1,715 @@
|
||||
# 8 — PORT BỘ CÔNG CỤ CHỈNH ẢNH TỪ WEB SANG APP ANDROID
|
||||
|
||||
Nhánh đích: `feat/vision-camera-v5` (base khi viết: **`d5e5da4`**). Ngày: 2026-09-26.
|
||||
Nguồn đối chiếu: nhánh **`recipes-web`**, commit **`6814b05`** (bản web đã chạy và đo được).
|
||||
|
||||
Tài liệu này là **spec để code**, không phải báo cáo. Mỗi mục có: nguồn web (file/hàm),
|
||||
hiện trạng Android, việc phải làm, mã mẫu, và **tiêu chí nghiệm thu**. Toàn bộ số dòng
|
||||
đã đối chiếu trực tiếp trên hai nhánh tại thời điểm viết.
|
||||
|
||||
> **Cách lấy nguồn tham chiếu:** nhánh `feat/vision-camera-v5` **không chứa** thư mục
|
||||
> `docker/frontend`. Đọc bản web bằng:
|
||||
>
|
||||
> ```sh
|
||||
> git show origin/recipes-web:docker/frontend/shared/utils/toneShader.ts
|
||||
> git show origin/recipes-web:docker/frontend/src/engine/exportEngine.ts
|
||||
> ```
|
||||
>
|
||||
> Đường dẫn "web" trong tài liệu này luôn tính từ `docker/frontend/`.
|
||||
|
||||
---
|
||||
|
||||
## 0. Phạm vi và bảng khoảng trống
|
||||
|
||||
Bảy hạng mục người dùng yêu cầu, theo đúng thứ tự sẽ làm:
|
||||
|
||||
| # | Hạng mục | Web có (nguồn) | Android hiện có | Việc phải làm |
|
||||
|---|----------|----------------|-----------------|----------------|
|
||||
| 1 | EXPOSURE / EV / HIGHLIGHT | `shared/utils/toneShader.ts` `EXPOSURE_SKSL` + `TONE_SKSL`; `shared/utils/colorUtils.ts` `EV_PER_UNIT`/`SIM_EXPOSURE_BIAS_EV`/`exposureStops` (`171-193`) | EV nhét trong ma trận màu (`src/utils/colorUtils.ts:425-426`, `applyExposureGain:444-455`); highlight knee `smoothstep(0.65,1.0)` gain `0.22` không có `(1-t)` (`src/utils/toneShader.ts:131,134`) | Tách EV sang pass linear; port đúng knee/`(1-t)` của web; **áp luôn fix thứ tự pass của `4_FIX_HIGHLIGHT_ORDER.md`** |
|
||||
| 2 | AUTO (auto setting) | `src/ui/Histogram.tsx` `readHistogram` + `autoExposureStops`; `src/App.tsx:2497-2504` | Không có (ROTATE có AUTO riêng cho straighten — khác việc) | Histogram 256 bin từ pixel ảnh gốc + `autoExposureStops`, ghi vào `exposureCompensation` |
|
||||
| 3 | WHITE / BLACK trong WB | `TONE_SKSL` web dòng `168-169`; uniform `wh`/`bl` (`78-79`, `338-339`, `390-391`, `433-434`, `454`, `466-467`); `paramDefs.ts:137,148` | Không có `whites`/`blacks`. `paramDefs.wb` chỉ có `temperature`,`tint` (`src/utils/paramDefs.ts:109-132`) | Thêm 2 uniform + 2 entry `paramDefs.wb` |
|
||||
| 4 | TONE CURVE | `shared/utils/toneCurve.ts` (203 dòng) + `src/ui/ToneCurvePanel.tsx` | Không có | Port toán + LUT + panel graph |
|
||||
| 5 | GRADIENT MASK | `shared/utils/gradientMask.ts` (169 dòng) + `App.tsx` `gradientChips:2750`/`maskRulers:2355` | Không có | Port shader + geometry + UI chip/strip/cột chỉnh |
|
||||
| 6 | FIX (HEAL, MOSAIC) | `shared/utils/heal.ts` (324), `mosaic.ts` (104), `brush.ts` (26) | Không có | Port shader + pixel sampler + UI |
|
||||
| 7 | H-FLIP / V-FLIP trong ROTATE | `shared/utils/skiaImage.ts` `flipSkImage:84`, `applyPhotoRotation:114` | `applyPhotoRotation(image, quarter, straighten)` — **không có flip** (`src/utils/skiaImage.ts:69`) | Thêm flip + state + UI strip |
|
||||
|
||||
### 0.1 Đã có sẵn trên nhánh này — KHÔNG làm lại
|
||||
|
||||
Các commit `c8e2b6e`…`d5e5da4` đã port xong và **không** nằm trong phạm vi tài liệu này:
|
||||
|
||||
- **HSL selective colour mixer** (`cbd0178`): đã có `hslBands`/`hslHue`/`hslSat`/`hslLum`
|
||||
trong `ColorAdjustments` (`src/types/index.ts:77-104`), `HSL_BANDS`/`hslBandGaps`/
|
||||
`sanitizeHslBands` trong `colorUtils.ts:11-115`, band block trong `TONE_SKSL`
|
||||
(`toneShader.ts:51-58,76-82,194`) và tab `HSL` trong `ToolRail.tsx:18`.
|
||||
⇒ **Bỏ** mọi ghi chú cũ kiểu "HSL web-only".
|
||||
- **Stock `classic-vivid` và `mono-high-contrast`** (`c8e2b6e`, `85b55dc`): đã có trong
|
||||
`BaseFilter` (`src/types/index.ts:69`) **và** đã có entry `FILM_TONE`
|
||||
(`src/utils/toneShader.ts:299-322`, gồm `'mono-high-contrast': { sh: -0.32, hl: 0.26 }`).
|
||||
- **`STOCK_BIAS`** (`colorUtils.ts:161-168`) đã thay vai trò của `SIM_EXPOSURE_BIAS_EV` +
|
||||
`SIM_CONTRAST_BIAS` bên web, nhưng còn ở **đơn vị cũ** — xem mục 3.3.
|
||||
|
||||
Ghi chú phạm vi khác: ba nhánh light/chroma, split-tone, Color Chrome, vibrance trong
|
||||
`TONE_SKSL` Android **đã khớp** web — không đụng.
|
||||
|
||||
---
|
||||
|
||||
## 1. Hiện trạng Android
|
||||
|
||||
### 1.1 Ba đường render — mọi pass mới phải khớp cả ba
|
||||
|
||||
| Đường | File | Đặc điểm |
|
||||
|-------|------|----------|
|
||||
| Export JS/Skia (imperative) | `src/utils/exportEngine.ts` → `processAndExportPhoto` | Toàn quyền: surface phụ, snapshot, LUT, cache effect theo count. Đây là đường "chuẩn" để so. |
|
||||
| Preview library (declarative) | `src/components/Viewfinder.tsx`, `libPhoto()` (`3127-3200`) | Chỉ lồng được `<Shader source={effect}>` quanh một chuỗi shader của ảnh. **Không** chèn được pass giữa tuỳ ý. |
|
||||
| Camera preview (worklet) | `Viewfinder.tsx` `<SkiaCamera onFrame={handleFrame}>`, `drawFullFrame` | Mỗi frame; tránh thêm pass nặng. |
|
||||
| Export native Kotlin | `src/utils/nativeExport.ts:47` + `modules/recipescam-export/android/.../RecipescamExportModule.kt` | Bật bằng `EXPO_PUBLIC_NATIVE_EXPORT=1` (`App.tsx:61`). Chỉ dùng khi `pro` (`App.tsx:1376`). |
|
||||
|
||||
`Viewfinder.tsx:461` dựng ảnh tĩnh đã quay cho chế độ library bằng memo
|
||||
`applyPhotoRotation(loadedImage, photoRotation, photoStraighten)`.
|
||||
|
||||
### 1.2 Hạ tầng đã có
|
||||
|
||||
- `Skia.RuntimeEffect.Make` + `makeShaderWithChildren` + `<Shader source>` — đã dùng
|
||||
(`exportEngine.ts:371-380`, `Viewfinder.tsx:3141-3146`).
|
||||
- Mẫu đọc pixel: `src/utils/horizon.ts:18-56` — `Skia.Surface.Make(w,h)` (`32`) →
|
||||
`drawImageRectOptions` downscale → `makeImageSnapshot()` → `readPixels()` (`50`)
|
||||
(Uint8Array RGBA hoặc Float32Array). Dùng lại cho histogram/AUTO và `findHealSource`.
|
||||
- Undo: `App.tsx` `commit(coalesce, mutate)` (`757-775`, `PEEK_GAP_MS = 700` tại `751`),
|
||||
`applyLook` (`804`), `handleUndo` (`820`), `Look` (`719-720`) — thêm field vào `Look`
|
||||
là tự động sống qua undo/redo/session (`lookNow` tại `728`).
|
||||
- Chip UI: `AdjustmentPanel.tsx` `chipRow` (`867`), `paramChips` (`475`),
|
||||
`groupDefs` (`652`), `groupDefs.rotate` (`713-733`), `rotateChip()` (`791-803`).
|
||||
- `RecipeCreateModal.handleSave` spread `...seedAdj`, nên knob **không có row** trong form
|
||||
vẫn được lưu (không bị zero).
|
||||
|
||||
### 1.3 Điều KHÔNG có (phải dựng mới)
|
||||
|
||||
Không có: brush, heal, mosaic, mask vẽ tay, curve editor, flip. Không SVG, không
|
||||
`react-native-gesture-handler` — mọi touch đi qua RN responder. Không i18n (chuỗi
|
||||
hardcode tiếng Anh, giữ nguyên).
|
||||
|
||||
`exportEngine.ts` **không** cache `RuntimeEffect` theo count: chỉ có `watermarkFonts`
|
||||
(`184`) ở module scope, mọi effect khác dựng lại trong hàm. Heal/mosaic/mask bắt buộc
|
||||
phải cache — xem mục 2.4.
|
||||
|
||||
### 1.4 Thứ tự pass hiện tại của `exportEngine.ts`
|
||||
|
||||
```
|
||||
218 1 nạp ảnh → 241 1 rotate → 281 1b crop → 250 1c deviceFloor → 337 2 surface
|
||||
350 3 color matrix (+ STOCK_BIAS, +2^EV gain) ← CÙNG paint với 3b/3c: SAI (xem doc 4)
|
||||
359 3b tone shader → 392 3c cinema
|
||||
414 4 denoise/clarity/soften → 477 5 draw → 489 5b HDF
|
||||
572 6 grain → 619 6b vignette → 648 7 frame
|
||||
832 8 watermark → 924 9 snapshot/JPEG/EXIF → 976 10 file → 988 11 gallery
|
||||
```
|
||||
|
||||
`4_FIX_HIGHLIGHT_ORDER.md` đã chỉ ra `SkPaint` chạy **shader TRƯỚC colorFilter**, nên
|
||||
ma trận (mang exposure) hiện đang áp **sau** tone → HIGHLIGHT bị xoá sạch. Kiểm tra
|
||||
`grep -rn gradeThrough src/` → **rỗng**, fix này **chưa được áp**. Mục 3 dưới đây áp nó
|
||||
cùng lúc với việc tách EV.
|
||||
|
||||
---
|
||||
|
||||
## 2. Quy tắc chung cho mọi pass mới
|
||||
|
||||
### 2.1 Thứ tự pass đích
|
||||
|
||||
```
|
||||
rotate(+flip) → crop → matrix → [EXPOSURE linear] → tone(hl/sh/wh/bl) → cinema
|
||||
→ curve → denoise/clarity/soften → draw → HDF
|
||||
→ masks → heal → mosaic → grain → vignette → frame → watermark
|
||||
```
|
||||
|
||||
### 2.2 Sai lệch **có chủ ý** so với web
|
||||
|
||||
Web đặt `masks → heal → mosaic` **sau** grain và vignette
|
||||
(`exportEngine.ts:708-800`). Android đặt chúng **trước** grain/vignette. Lý do:
|
||||
|
||||
1. Preview library chỉ lồng được shader trong chuỗi shader của ảnh — mask/heal/mosaic
|
||||
phải nằm trong chuỗi đó, tức trước lớp grain/vignette vốn là node riêng
|
||||
(`Viewfinder.tsx:3230-3233`/`3288-3291` grain, `renderVignette:2809,3237,3295,3348`).
|
||||
2. Grain/vignette chạy sau sẽ **phủ đều** lên vết sửa, nên vết heal/mosaic trông tự
|
||||
nhiên hơn (nhiễu hạt chồng lên đúng như mọi vùng khác).
|
||||
|
||||
Điều phải giữ bằng mọi giá: `masks` chạy **trước** `heal` (web có comment giải thích ở
|
||||
`gradientMask.ts:149-155` và `exportEngine.ts:708-737`) — vết heal phải mượn pixel **đã
|
||||
mang ánh sáng của mask**.
|
||||
|
||||
### 2.3 Điểm chèn
|
||||
|
||||
| Đường | Vị trí chèn |
|
||||
|-------|-------------|
|
||||
| `exportEngine.ts` | Ngay sau khối vignette, trước `// 7. Frame.` (`648`) |
|
||||
| `Viewfinder.tsx` | Trong `libPhoto()` (`3127`), sau `<Blur>`/clarity và trước HDF glow |
|
||||
| `nativeExport.ts` / Kotlin | Theo `4_FIX_HIGHLIGHT_ORDER.md` §4.3 rồi nối tiếp |
|
||||
|
||||
### 2.4 Quy ước code
|
||||
|
||||
- Shader dựng theo **số lượng** phần tử (`healSkSL(n)`, `mosaicSkSL(n)`,
|
||||
`gradientMaskSkSL(n)`) và **cache** effect theo count — copy mẫu web
|
||||
`exportEngine.ts:154-210` (`healEffectFor`/`mosaicEffectFor`/`maskEffectFor`).
|
||||
- Mọi pass mới phải có nhánh "tắt": uniform toàn 0 / danh sách rỗng ⇒ **không** tạo
|
||||
surface, **không** đổi đường vẽ cũ. Đây là điều kiện để không regress ảnh hiện tại.
|
||||
- Không đổi ngữ nghĩa `evFromCamera` (`exportEngine.ts:35,353`): ảnh chụp từ camera đã
|
||||
nhận AE bias phần cứng, **không** cộng thêm EV của `exposureCompensation`.
|
||||
|
||||
---
|
||||
|
||||
## 3. EXPOSURE / EV / HIGHLIGHT
|
||||
|
||||
### 3.1 Nguồn web
|
||||
|
||||
- `shared/utils/toneShader.ts` — `EXPOSURE_SKSL`: linear hoá sRGB (`0.04045/12.92` +
|
||||
`2.4`), nhân `exp2(ev)`, encode lại. Chạy **giữa** ảnh đã grade và tone shader.
|
||||
- `shared/utils/colorUtils.ts:171-193`:
|
||||
- `SIM_EXPOSURE_BIAS_EV = { 'leica-vivid': 0.25 }`, `SIM_CONTRAST_BIAS = { 'mono-high-contrast': 4 }`
|
||||
- `EV_PER_UNIT = 0.25` → núm EXPOSURE ±10 = ±2.5 EV
|
||||
- `exposureStops(adj, baseFilter) = (adj.exposure ?? 0) * EV_PER_UNIT + bias`
|
||||
- Comment khẳng định: EV/biến thiên sim **không** nằm trong ma trận màu.
|
||||
- `exportEngine.ts:458-476` — bước 3: ma trận riêng một ảnh, rồi `evStops = exposureStops() + userEv`.
|
||||
|
||||
### 3.2 Hiện trạng Android (sai)
|
||||
|
||||
`src/utils/colorUtils.ts`:
|
||||
|
||||
```
|
||||
186 const exposure = adj.exposure + (STOCK_BIAS[baseFilter]?.exposure ?? 0);
|
||||
425 const expScale = 1 + (exposure / 10) * 0.2; // -10 → 0.8x, +10 → 1.2x
|
||||
426 const expOffset = (exposure / 10) * 0.15;
|
||||
444 export function applyExposureGain(matrix, evStops) // gain 2^ev trong gamma-space
|
||||
```
|
||||
|
||||
`exposureCompensation` ±3 EV (`src/types/index.ts:104`), áp qua `applyExposureGain` tại
|
||||
`exportEngine.ts:353-357` và `nativeExport.ts:61`. Kết quả: +1 EV đẩy mid-grey 0.5 →
|
||||
**1.0** (cháy), đúng như web đã sửa.
|
||||
|
||||
`STOCK_BIAS['leica-vivid'] = { exposure: 2 }` (`colorUtils.ts:163`) là **đơn vị cũ**: web
|
||||
đo ra ≈ **0.25 EV** và đã chuyển sang thang stops.
|
||||
|
||||
### 3.3 Việc phải làm
|
||||
|
||||
**Bước A — tách EV khỏi ma trận.**
|
||||
|
||||
1. `colorUtils.ts:186` — bỏ `+ (STOCK_BIAS[baseFilter]?.exposure ?? 0)`; xoá khối
|
||||
`expScale`/`expOffset` (`425-438`) khỏi `getSkiaColorMatrix`; xoá
|
||||
`applyExposureGain` (`444-455`) khỏi mọi caller. Giữ `STOCK_BIAS.contrast`
|
||||
(`mono-high-contrast: 4`) — web cũng giữ `SIM_CONTRAST_BIAS` trong ma trận.
|
||||
2. Đổi `STOCK_BIAS['leica-vivid']` từ `{ exposure: 2 }` → **bỏ khỏi `STOCK_BIAS`**, và
|
||||
thêm đúng bản web:
|
||||
|
||||
```ts
|
||||
// src/utils/colorUtils.ts
|
||||
export const EV_PER_UNIT = 0.25; // núm EXPOSURE ±10 = ±2.5 EV (web: giống hệt)
|
||||
const SIM_EXPOSURE_BIAS_EV: Partial<Record<BaseFilter, number>> = { 'leica-vivid': 0.25 };
|
||||
|
||||
/** EV người dùng (núm), biến thiên sim, và EV thô — cộng ở thang stops. */
|
||||
export function exposureStops(
|
||||
adj: ColorAdjustments, baseFilter: BaseFilter | undefined, userEv: number
|
||||
): number {
|
||||
return (adj.exposure ?? 0) * EV_PER_UNIT
|
||||
+ (SIM_EXPOSURE_BIAS_EV[baseFilter ?? 'none'] ?? 0)
|
||||
+ userEv;
|
||||
}
|
||||
```
|
||||
|
||||
**Bước B — thêm `EXPOSURE_SKSL`** vào `src/utils/toneShader.ts` (copy nguyên văn web,
|
||||
giữ comment gốc):
|
||||
|
||||
```ts
|
||||
export const EXPOSURE_SKSL = `
|
||||
uniform shader src;
|
||||
uniform float ev;
|
||||
vec3 toLinear(vec3 c) {
|
||||
return mix(c / 12.92, pow((c + 0.055) / 1.055, vec3(2.4)), step(vec3(0.04045), c));
|
||||
}
|
||||
vec3 toEncoded(vec3 c) {
|
||||
return mix(c * 12.92, 1.055 * pow(c, vec3(1.0 / 2.4)) - 0.055, step(vec3(0.0031308), c));
|
||||
}
|
||||
vec4 main(vec2 xy) {
|
||||
vec4 c = src.eval(xy);
|
||||
vec3 rgb = clamp(c.rgb, 0.0, 1.0);
|
||||
return vec4(clamp(toEncoded(toLinear(rgb) * exp2(ev)), 0.0, 1.0), c.a);
|
||||
}
|
||||
`;
|
||||
```
|
||||
|
||||
**Bước C — áp `4_FIX_HIGHLIGHT_ORDER.md` §4.1/§4.2/§4.3 nguyên trạng.** Ma trận phải
|
||||
thành ảnh riêng (`gradeThrough`) trước khi tone/cinema đọc. Đồng thời **bỏ lần áp ma
|
||||
trận thứ hai** trong khối HDF (`exportEngine.ts:529,549`, `Viewfinder.tsx:1369,1387`).
|
||||
|
||||
Thứ tự sau khi sửa: `matrix → exposure(linear) → tone → cinema`. Exposure chèn cùng chỗ
|
||||
với ma trận: `gradeImage` (kết quả `gradeThrough`) → shader exposure → tone shader con.
|
||||
`ev = exposureStops(adjustments, recipe.baseFilter, evFromCamera ? 0 : exposureCompensation)`.
|
||||
Với `evFromCamera` thì **vẫn** giữ phần núm EXPOSURE + biến thiên sim (chúng chưa từng
|
||||
đi qua phần cứng), chỉ bỏ `exposureCompensation`.
|
||||
|
||||
### 3.4 Nghiệm thu
|
||||
|
||||
1. Parity với web trên **cùng ảnh + cùng recipe** (so pixel, không so cảm giác).
|
||||
2. Mid-grey +1 EV cho ra **≈ 0.69** encoded, **không** cháy 1.00.
|
||||
3. Bảng p95 của `4_FIX_HIGHLIGHT_ORDER.md` §5: nguồn gradient dọc 0.98→0.30,
|
||||
`HIGHLIGHT −10` → p95 **0.780**, `HIGHLIGHT −10 + EXPOSURE +10` → p95 **vẫn 0.780**,
|
||||
midtone 0.502 → 0.722.
|
||||
4. `leica-vivid` sau khi sửa sáng thêm **≈ 0.25 EV** so với trước (đo trên cùng ảnh), và
|
||||
**giống** bản web.
|
||||
5. Chạy đủ ba đường (export JS, preview library, Kotlin) và so ảnh với nhau.
|
||||
|
||||
> **Thay đổi hành vi phải báo trước:** núm EXPOSURE từ "±10 đơn vị ≈ ×0.8…×1.2" thành
|
||||
> "±2.5 EV". Recipe cũ đặt EXPOSURE ≠ 0 sẽ sáng/tối hơn rõ rệt. Web đã chấp nhận đánh
|
||||
> đổi này (`colorUtils.ts` comment "every recipe already saved with a non-zero EXPOSURE
|
||||
> gets brighter with it").
|
||||
|
||||
---
|
||||
|
||||
## 4. HIGHLIGHT
|
||||
|
||||
### 4.1 Nguồn web (`TONE_SKSL`)
|
||||
|
||||
```
|
||||
57 float hlMask = smoothstep(0.50, 1.00, t);
|
||||
60 float o = t + hl * hlMask * (1.0 - t) + sh * 0.34 * shMask;
|
||||
63 o -= dr * 0.18 * hlMask * t;
|
||||
```
|
||||
|
||||
Knee **0.50**, biên độ **1.0** (không scale), dạng **`(1-t)`** (càng gần trắng càng ít
|
||||
đẩy) ⇒ monotonic, giữ được vùng sáng không cháy. `shMask = 1 - smoothstep(0, 0.55, t)`.
|
||||
|
||||
### 4.2 Hiện trạng Android (`src/utils/toneShader.ts`)
|
||||
|
||||
```
|
||||
131 float hlMask = smoothstep(0.65, 1.00, t);
|
||||
134 float o = t + hl * 0.22 * hlMask + sh * 0.34 * shMask;
|
||||
```
|
||||
|
||||
Knee 0.65, gain 0.22, cộng thẳng không có `(1-t)` ⇒ vùng sáng đã cháy vẫn bị đẩy lên và
|
||||
thanh "chết" đúng như `4_FIX_HIGHLIGHT_ORDER.md` §1 mô tả.
|
||||
|
||||
### 4.3 Việc phải làm
|
||||
|
||||
Thay hai dòng `131` và `134` bằng bản web. Giữ `sh * 0.34` (hai bên giống nhau). Không
|
||||
đổi `dr` (dòng `137`, đã khớp). Cập nhật comment đầu file (mô tả knee 0.65) cho khớp.
|
||||
|
||||
`FILM_TONE` (`toneShader.ts:299-322`) đã có đủ sáu stock web hỗ trợ, gồm
|
||||
`classic-vivid` và `mono-high-contrast` — **không đụng**.
|
||||
|
||||
### 4.4 Nghiệm thu
|
||||
|
||||
- `HIGHLIGHT −10` trên ảnh có vùng trắng đẩy p95 xuống ~0.78 và **không** đổi midtone
|
||||
(so với ảnh gốc, mid lệch < 0.01).
|
||||
- `HIGHLIGHT +10` rồi `−10` quay về đúng ảnh gốc (đối xứng, sai số ≤ 1/255).
|
||||
- `mono-high-contrast` (dùng `hl: 0.26`) giữ nguyên diện mạo sau khi đổi knee — kiểm bằng
|
||||
ảnh so trước/sau.
|
||||
|
||||
---
|
||||
|
||||
## 5. WHITE / BLACK trong WB
|
||||
|
||||
### 5.1 Nguồn web
|
||||
|
||||
`TONE_SKSL` — chèn **sau** nhánh lightness/chroma, trước split-tone:
|
||||
|
||||
```
|
||||
168 vec3 dk = 1.0 - rgb;
|
||||
169 rgb = clamp(rgb + bl * 0.18 * dk * dk * dk + wh * 0.18 * rgb * rgb * rgb, 0.0, 1.0);
|
||||
```
|
||||
|
||||
Trọng số **cubic** theo khoảng cách của kênh tới đầu mút: trong tối kênh tối nhất dịch
|
||||
nhiều nhất, trong sáng kênh sáng nhất dịch nhiều nhất ⇒ kéo R/G/B về chung một toe và
|
||||
shoulder — đây là **động tác white balance**, không phải thêm một thanh tone. Monotonic:
|
||||
đạo hàm `1 − 3×0.18 = 0.46` tại đầu mút, không thể đảo chiều.
|
||||
|
||||
Uniform web: `uniform float wh; uniform float bl;` (`toneShader.ts:78-79`), nhận
|
||||
`Math.max(-1, Math.min(1, (adj.whites ?? 0) / 10))` (`390-391`), đưa vào mảng đúng thứ tự
|
||||
khai báo sau `dr, hl, sh` (`454`), và `toneIsActive` thêm `u.wh !== 0 || u.bl !== 0`
|
||||
(`466-467`).
|
||||
|
||||
`shared/utils/paramDefs.ts:137,148` — thứ tự WB: `temperature, tint, whites` (label
|
||||
`WHITE`), `blacks` (`BLACK`), min/max ±10, step 1, `display: sign`.
|
||||
|
||||
### 5.2 Việc phải làm
|
||||
|
||||
1. `src/types/index.ts` — thêm `whites?: number; // -10..+10` và `blacks?: number` vào
|
||||
`ColorAdjustments` (interface ở dòng `77`), đặt ngay sau `tint`/`wbBlue` cho khớp web.
|
||||
2. `src/utils/toneShader.ts`:
|
||||
- thêm `uniform float wh; uniform float bl;` **ngay sau** `uniform float sh;` (dòng `66`);
|
||||
- thêm khối `dk` ở đúng vị trí web (sau nhánh lightness/chroma, trước split-tone ở `147`);
|
||||
- thêm `wh`/`bl` vào `ToneUniforms` (`275`), `getToneUniforms` (`325`, chia 10 và kẹp ±1),
|
||||
`toneUniformArray` (`388`, đúng thứ tự khai báo), `toneIsActive` (`396`).
|
||||
3. `src/utils/paramDefs.ts:109-132` — thêm 2 entry vào `wb` sau `tint`, label
|
||||
`WHITE`/`BLACK`, ±10 step 1, `display: sign`, `get: (a) => a.whites ?? 0`,
|
||||
`set: (v) => ({ whites: v })` (tương tự `blacks`). `chipRow` của tab WB
|
||||
(`AdjustmentPanel.tsx:957`) tự hiện chip — không phải sửa UI.
|
||||
4. `RecipeCreateModal` — **không cần** thêm row: `handleSave` đã spread `seedAdj`.
|
||||
|
||||
### 5.3 Nghiệm thu
|
||||
|
||||
- `WHITE +10` chỉ nâng vùng sáng, vùng tối và mid lệch < 1/255.
|
||||
- `BLACK +10` chỉ nâng vùng tối (đen thành xám nhẹ), vùng sáng lệch < 1/255.
|
||||
- Trên ảnh xám trung tính, `WHITE −10` **không** sinh cast màu (ba kênh dịch như nhau).
|
||||
- Recipe cũ (không có `whites`/`blacks`) render **y hệt** trước khi sửa.
|
||||
|
||||
---
|
||||
|
||||
## 6. AUTO (auto setting)
|
||||
|
||||
### 6.1 Nguồn web
|
||||
|
||||
`src/ui/Histogram.tsx`:
|
||||
|
||||
```ts
|
||||
const BINS = 256; // 0..255
|
||||
const SAMPLE = 320; // downscale cạnh dài trước khi bin
|
||||
export const AUTO_EV_MAX = 2.5;
|
||||
|
||||
export function autoExposureStops(lum: number[]): number {
|
||||
let sum = 0, weighted = 0;
|
||||
for (let i = 0; i < lum.length; i++) { sum += lum[i]; weighted += (i / (lum.length - 1)) * lum[i]; }
|
||||
const avg = Math.max(0.001, sum > 0 ? weighted / sum : 0);
|
||||
const stops = Math.log2(0.48 / avg);
|
||||
return Math.max(-AUTO_EV_MAX, Math.min(AUTO_EV_MAX, stops));
|
||||
}
|
||||
```
|
||||
|
||||
Luma weights `0.2126/0.7152/0.0722` (đúng trọng số shader dùng). Target **0.48**, guard
|
||||
`≥ 0.001`, kẹp ±2.5 EV.
|
||||
|
||||
`src/App.tsx:2497-2504` `autoExposure()`: đọc histogram của **ảnh gốc đã load**, rồi
|
||||
`setAdjustment({ exposureCompensation: Math.round(stops * 10) / 10 })`. Chip `AUTO` đứng
|
||||
**đầu** row tab LIGHT (`App.tsx:2586-2587`).
|
||||
|
||||
### 6.2 Việc phải làm (Android)
|
||||
|
||||
1. Thêm `src/utils/histogram.ts`:
|
||||
|
||||
```ts
|
||||
export const AUTO_EV_MAX = 2.5;
|
||||
|
||||
// Pixel đã downscale (cạnh dài 320) từ readPixels — cùng mẫu horizon.ts:32-56.
|
||||
export function lumaBins(rgba: Uint8Array | Float32Array, isFloat: boolean): number[] { /* 256 bin */ }
|
||||
export function autoExposureStops(lum: number[]): number { /* y như web */ }
|
||||
```
|
||||
|
||||
Nguồn pixel: `Skia.Surface.Make` + `drawImageRectOptions` downscale về cạnh dài **320**
|
||||
trên **ảnh gốc đã load** (rotation không đổi histogram, không cần `applyPhotoRotation`),
|
||||
theo đúng mẫu `src/utils/horizon.ts:32-56`. Nhớ `dispose()` surface/snapshot — Hermes
|
||||
không tự thu hồi buffer native (`exportEngine.ts:113-121`).
|
||||
|
||||
2. Chip `AUTO` **đứng đầu** row LIGHT: `AdjustmentPanel.tsx:951` hiện là
|
||||
`chipRow([...paramChips(paramDefs.iq), groupChip('dr')])` → đổi thành
|
||||
`chipRow([autoChip(), ...paramChips(paramDefs.iq), groupChip('dr')])`, nhãn `AUTO`.
|
||||
3. `onPress`: chạy `autoExposureStops` → `handleUpdateAdjustments({ exposureCompensation: ev })`
|
||||
(`App.tsx:869`). Đi qua `handleUpdateAdjustments` để có `commit(true, …)` ⇒ **một bước undo**.
|
||||
|
||||
### 6.3 Nghiệm thu
|
||||
|
||||
- **Idempotent:** bấm AUTO hai lần liên tiếp ra cùng một giá trị EV (vì đọc ảnh gốc,
|
||||
không đọc ảnh đã áp EV).
|
||||
- Ảnh phơi sáng đúng (avg ≈ 0.48) ⇒ EV ≈ 0 (|EV| ≤ 0.1).
|
||||
- Ảnh tối đen ⇒ EV = +2.5 (không `Infinity`, không `NaN`).
|
||||
- Ảnh cháy trắng ⇒ EV = −2.5.
|
||||
- Undo một lần quay về EV trước khi bấm.
|
||||
|
||||
Ngoài phạm vi: web **chỉ** auto EV. Không auto contrast/white balance/tone.
|
||||
|
||||
---
|
||||
|
||||
## 7. TONE CURVE
|
||||
|
||||
### 7.1 Nguồn web — `shared/utils/toneCurve.ts` (203 dòng)
|
||||
|
||||
| Hàm/hằng | Vai trò |
|
||||
|----------|---------|
|
||||
| `CURVE_CHANNELS = ['rgb','r','g','b']` | master + 3 kênh |
|
||||
| `CURVE_LUT_SIZE = 256`, `IDENTITY_CURVE = [[0,0],[1,1]]` | LUT 256 mức |
|
||||
| `CURVE_MIN_GAP = 0.02`, `FLAT = 0.002` | khoảng cách điểm tối thiểu; ngưỡng "phẳng" |
|
||||
| `curvePoints(curve, ch)` | clamp01 + sort + ghim hai đầu về (0,0) và (1,1) |
|
||||
| `curveIsActive(curve)` | có kênh nào lệch `FLAT` khỏi đường chéo |
|
||||
| `tangents(pts)` | tiếp tuyến **Fritsch–Carlson** (monotone cubic) |
|
||||
| `sampleCurve(pts, x)`, `curveLut(curve)` | master ∘ kênh → `Uint8Array` RGBA, A=255 |
|
||||
| `CURVE_SKSL` | 2 child (`src`, `lut`), sample `v * 255 + 0.5` |
|
||||
| `addCurvePoint` | thêm điểm **trên** đường (click không nhảy giá trị) |
|
||||
| `moveCurvePoint` | hai đầu bị ghim theo trục x |
|
||||
| `removeCurvePoint`, `isFlatCurve` | xoá điểm, kiểm tra phẳng |
|
||||
|
||||
`curveLut` áp theo thứ tự **master ∘ channel**.
|
||||
|
||||
### 7.2 UI web — `src/ui/ToneCurvePanel.tsx` (357), `src/App.tsx`
|
||||
|
||||
- Graph `SIZE = 224`, `STEPS = 64` vẽ đường, `HIT = 11px` bán kính bắt điểm.
|
||||
- Tabs `RGB / R / G / B` + `RESET` (`{ }` = xoá toàn bộ curve).
|
||||
- Histogram vẽ **sau** lưu đồ (nền).
|
||||
- Kéo card bằng head; pointer capture khi kéo điểm.
|
||||
- `put()` xoá channel khi phẳng (không lưu curve rỗng).
|
||||
- Chip `TONE CURVE` nằm sau group D.RANGE trong row LIGHT; amber khi `curveIsActive`.
|
||||
|
||||
### 7.3 Việc phải làm (Android)
|
||||
|
||||
1. `src/utils/toneCurve.ts` — port nguyên toán + hằng số + `CURVE_SKSL`. `curveLut` trả
|
||||
`Uint8Array(256 * 4)`.
|
||||
2. Dựng LUT thành ảnh Skia cho shader: `Skia.Image.MakeImage({ width: 256, height: 1,
|
||||
colorType: RGBA_8888, alphaType: Premul }, data, 256 * 4)`; shader con thứ hai là
|
||||
`lutImage.makeShaderOptions(TileMode.Clamp, TileMode.Clamp, FilterMode.Linear,
|
||||
MipmapMode.None)` — như web (`exportEngine.ts:533-560`, khối 3e).
|
||||
3. `ColorAdjustments.toneCurve?: ToneCurve`.
|
||||
4. Chip `TONE CURVE` trong row LIGHT **sau** `groupChip('dr')` (`AdjustmentPanel.tsx:951`);
|
||||
mở panel graph khi `openParam === 'curve'`; amber khi `curveIsActive`.
|
||||
5. Panel: graph 224px dựng bằng Skia (`<Canvas>` + `<Path>` + `<Circle>`), tabs
|
||||
RGB/R/G/B, RESET, histogram nền (dùng lại hàm của mục 6), kéo điểm bằng RN responder
|
||||
trên `<View>` phủ (không có pointer capture sẵn — dùng `PanResponder`), tap-trên-đường
|
||||
để thêm điểm, double-tap để xoá (`CURVE_MIN_GAP 0.02`, HIT 11px).
|
||||
6. Một cử chỉ kéo = **một** bước undo: copy cách `handleUpdateAdjustments` gộp
|
||||
(`App.tsx:869`, `PEEK_GAP_MS = 700` tại `751`).
|
||||
|
||||
### 7.4 Nghiệm thu
|
||||
|
||||
- Curve identity ⇒ ảnh **không đổi** (diff = 0) và pass bị bỏ (không tạo LUT/surface).
|
||||
- Master kéo 1/4 → 0 làm nửa dưới tối đúng theo đường monotone; `R` kéo xuống ⇒ ảnh ngả
|
||||
cyan, và **chỉ** kênh đỏ đổi.
|
||||
- `Fritsch–Carlson` không overshoot: LUT monotone khi các điểm vào monotone.
|
||||
- Export == preview (cùng LUT).
|
||||
- **Rủi ro cao cần kiểm trên device:** nhánh declarative `<Shader source={curveEffect}>
|
||||
{graded}</Shader>` cần **2 child shader** (chuỗi đã grade + `ImageShader` của LUT với
|
||||
`fit="none"` và `rect={rect(0, 0, 256, 1)}`). Nếu `@shopify/react-native-skia` không
|
||||
nhận `rect` như mong đợi, fallback: render curve trong đường **imperative** cho preview
|
||||
tĩnh (dùng đúng `gradeThrough` của mục 3), và giữ declarative chỉ khi tone/cinema.
|
||||
Đường export imperative thì không có rủi ro này.
|
||||
|
||||
---
|
||||
|
||||
## 8. GRADIENT MASK
|
||||
|
||||
### 8.1 Nguồn web — `shared/utils/gradientMask.ts` (169 dòng)
|
||||
|
||||
Hằng số: `MASK_KIND = { linear: 0, radial: 1 }`, `MASK_EXPOSURE_MAX = 5`,
|
||||
`MASK_DEFAULT_FEATHER = 0.5`, `MASK_MIN = 0.01`.
|
||||
|
||||
`maskUniforms` — layout `(3n + 1) × vec4`:
|
||||
|
||||
| offset | nội dung |
|
||||
|--------|----------|
|
||||
| `i * 4` | `[x, y, ex, ey]` — gốc (pin) và đầu kéo (linear) |
|
||||
| `(n + i) * 4` | `[rx, ry, angle, feather]` — ellipse (radial) |
|
||||
| `(2n + i) * 4` | `[exposure, contrast / 10, saturation / 10, kind]` |
|
||||
| `3n * 4` | `[width, height, 0, 0]` — khung mà các phân số là của nó |
|
||||
|
||||
Alpha (web, `maskBlock`): linear `a = smoothstep(0, 1, clamp(dot(pos − pin·size, d) / |d|²))`;
|
||||
radial `st = (pos − center·size) / size.x`, xoay `−angle`, `d = length(r / max(rx, ry, 1e-5))`,
|
||||
`a = 1 − smoothstep(max(0, 1 − feather), 1, d)`. `maskAdjust` = exposure (luỹ thừa 2)
|
||||
→ contrast quanh 0.5 → saturation như mix khỏi luma REC-709, rồi clamp.
|
||||
Chạy **trước** HEAL (`gradientMask.ts:149-155`, `exportEngine.ts:708-737`).
|
||||
|
||||
`readMasks` lọc: kind hợp lệ, kẹp trong khung, và loại mask suy biến — linear có
|
||||
`hypot(ex−x, ey−y) > MASK_MIN`, radial có `rx > 0 && ry > 0`.
|
||||
|
||||
### 8.2 UI web (`App.tsx`)
|
||||
|
||||
- Chip `GRADIENT MASK` trong row FX, amber khi `masks.length > 0` (`2636`).
|
||||
- `gradientChips` (`2750-2765`): `LINEAR` / `RADIAL` / `CLEAR`.
|
||||
- Cột chỉnh mask (`3136-3155`) + `maskRulers` (`2355-2406`): EXPOSURE ±5 EV step 0.1,
|
||||
CONTRAST ±10, SATURATION ±10, FEATHER 0-100% (chỉ radial), DELETE.
|
||||
- Geometry kéo: `maskPin` / `maskFromDrag` / `maskDragged` (`121-230`).
|
||||
- `setMaskKnob` (`1123-1145`).
|
||||
|
||||
### 8.3 Việc phải làm (Android)
|
||||
|
||||
1. `src/utils/gradientMask.ts` — port hằng số + `readMasks` + `maskUniforms` +
|
||||
`maskAdjust` + `gradientMaskSkSL(count)`.
|
||||
2. **Thêm uniform `origin`.** Web vẽ lên canvas chính là ảnh, nên `pos` đã là toạ độ ảnh.
|
||||
Preview Android có `dispRect` (`Viewfinder.tsx:1705,3331`) nên shader phải dùng
|
||||
`pos − origin` trước khi chia `size`. Gói `origin` vào `vec4 size` đang có
|
||||
(`[width, height, originX, originY]`) để **không** đổi layout 3n+1.
|
||||
3. `GradientMask` interface (kind, x, y, ex, ey, rx, ry, angle, feather, exposure,
|
||||
contrast, saturation) + `ColorAdjustments.masks?: GradientMask[]`.
|
||||
4. Pass: cache `maskEffectFor(n)` (mẫu web `exportEngine.ts:204-210`), snapshot → clear →
|
||||
vẽ lại như web (`exportEngine.ts:708-737`).
|
||||
5. UI: chip `GRADIENT MASK` trong row FX + strip `LINEAR`/`RADIAL`/`CLEAR`
|
||||
+ cột chỉnh (EXPOSURE ±5 EV 0.1 / CONTRAST ±10 / SATURATION ±10 / FEATHER 0-100%
|
||||
chỉ radial / DELETE). Touch: RN responder trên `<View>` phủ — kéo pin/kéo đầu/đổi
|
||||
bán kính ellipse; vẽ outline + handle + đường chéo bằng Skia.
|
||||
6. Drag < `MASK_MIN` khi thả ⇒ huỷ, không tạo mask (khớp `readMasks`).
|
||||
|
||||
### 8.4 Nghiệm thu
|
||||
|
||||
- `+2 EV` trong mask làm vùng trong mask sáng **≈ 2 stop**, vùng ngoài lệch < 1/255.
|
||||
- Hai mask chồng nhau: mask thứ hai đọc kết quả mask thứ nhất (đúng "stack").
|
||||
- Kéo thả < 0.01 không sinh mask rác trong state.
|
||||
- MASK chạy trước HEAL: heal đặt trong mask mượn pixel đã mang ánh sáng mask (ảnh kiểm:
|
||||
vết heal trên vùng mask tối không còn sáng hơn nền).
|
||||
- FEATHER = 0 (radial) ⇒ rìa cứng, `a` nhảy 1 → 0 tại `d = 1`.
|
||||
- Toạ độ đúng khi zoom/pan: mask dán vào **ảnh**, không dán vào khung nhìn
|
||||
(kiểm bằng cách zoom rồi so vị trí outline với pixel).
|
||||
|
||||
---
|
||||
|
||||
## 9. FIX — HEAL và MOSAIC
|
||||
|
||||
### 9.1 Nguồn web
|
||||
|
||||
`shared/utils/heal.ts` (324 dòng) — mọi hằng số phải giữ nguyên:
|
||||
|
||||
```
|
||||
HEAL_FEATHER 0.85 HEAL_DEFAULT_R 0.012
|
||||
SEARCH_DISTANCES [2.6, 4.2, 6.5] SEARCH_DIRS 8 RING_R 1.15 RING_PAD 1
|
||||
RING_TAPS: 12 điểm vành, lấy MEDIAN INSIDE_TAPS: 16
|
||||
LIGHT_GATE 20 LIGHT_WEIGHT 3 BLEND_TAPS 16
|
||||
```
|
||||
|
||||
- `healUniforms` — `(2n + 1)` vec4 (mỗi spot 2 vec4: vị trí/bán kính + nguồn `sx,sy`).
|
||||
- `healSkSL(count)` — `correction` theo góc, sample `pos + ps − pd`.
|
||||
- `findHealSource(sample, x, y, r)` — 8 hướng × 3 khoảng × bản mirror, tính điểm lệch
|
||||
sáng có trọng số (`LIGHT_WEIGHT 3`, `LIGHT_GATE 20`), từ chối (`null`) nếu không có
|
||||
nguồn hợp lệ.
|
||||
|
||||
`shared/utils/mosaic.ts` (104) — `MOSAIC_CELL 0.02`, `MOSAIC_DEFAULT_R 0.05`;
|
||||
`mosaicUniforms` `(n + 1)` vec4, vec4 cuối `[width, height, cell, 0]`,
|
||||
`cell = max(1, MOSAIC_CELL * width)`; `mosaicSkSL(count)` lấy ô
|
||||
`img.eval((floor(pos / cell) + 0.5) * cell)`, **rim cứng**.
|
||||
|
||||
`shared/utils/brush.ts` (26) — `BRUSH_MIN_R 0.003`, `BRUSH_MAX_R 0.25`,
|
||||
`BRUSH_SPACING 0.6`, `wheelBrushR(r, deltaY)`.
|
||||
|
||||
Types web: `HealSpot { x, y, r, sx, sy }`, `MosaicSpot { x, y, r }`.
|
||||
|
||||
### 9.2 UI web (`App.tsx`)
|
||||
|
||||
- Chip `FIX` amber khi heal hoặc mosaic có spot; `fixChips` (`2715-2745`): `HEAL` /
|
||||
`MOSAIC` kèm readout `%`, `CLEAR`, `brushTool`.
|
||||
- Chọn spot HEAL để kéo và có nút `×` xoá; MOSAIC **không** kéo được.
|
||||
- `spotUnder` bắt theo bán kính `max(4, r * box.width)`.
|
||||
- Một cử chỉ vẽ = một bước undo.
|
||||
|
||||
### 9.3 Việc phải làm (Android)
|
||||
|
||||
1. `src/utils/brush.ts`, `heal.ts`, `mosaic.ts` — port **nguyên hằng số và công thức**,
|
||||
gồm `findHealSource`.
|
||||
2. Pixel sampler cho `findHealSource`: dùng đúng mẫu `horizon.ts:32-56` — render ảnh
|
||||
(đã grade, **chưa** mask/heal) xuống surface cạnh dài ~512, `readPixels()` →
|
||||
`Uint8Array` RGBA. Đây là "sample" đầu vào. **Không** dùng ảnh preview đã áp mask.
|
||||
3. UI: chip `FIX` trong row FX + strip `HEAL` / `CLEAR` / `MOSAIC` / `CLEAR`.
|
||||
Android **không có con lăn** ⇒ thêm một row slider `SIZE` (thay `wheelBrushR`), map
|
||||
tuyến tính vào `BRUSH_MIN_R..BRUSH_MAX_R`.
|
||||
4. HEAL: chạm/kéo tạo spot, chạm vào spot để chọn, kéo để dời, nút `×` để xoá.
|
||||
MOSAIC: chạm/kéo tạo spot, không dời. Một cử chỉ = `commit(true, …)` một bước undo.
|
||||
5. Shader theo count + cache (`healEffectFor(n)`, `mosaicEffectFor(n)`), snapshot → clear
|
||||
→ vẽ lại. Thứ tự: **masks → heal → mosaic** (web `exportEngine.ts:708-737` (masks), `740-775` (heal), `776-800` (mosaic)).
|
||||
6. Nếu `findHealSource` trả `null`: **giữ nguyên** pixel gốc (không bịa nguồn).
|
||||
|
||||
### 9.4 Nghiệm thu
|
||||
|
||||
- Xoá hạt bụi trên nền phẳng (trời, tường): vết sửa không lộ, seam viền ≤ ~2/255 so với
|
||||
nền (đo dọc vành spot).
|
||||
- Không có nguồn hợp lệ (hạt nằm trên biên tương phản cao) ⇒ ảnh **không đổi**.
|
||||
- Mosaic rim **cứng** — kiểm pixel tại rìa: nhảy bậc, không gradient.
|
||||
- 30 spot vẫn render (đo thời gian; cache effect hoạt động, không dựng lại shader mỗi frame).
|
||||
- Export == preview.
|
||||
|
||||
---
|
||||
|
||||
## 10. H-FLIP / V-FLIP trong ROTATE
|
||||
|
||||
### 10.1 Nguồn web — `shared/utils/skiaImage.ts`
|
||||
|
||||
```
|
||||
84 export function flipSkImage(image, horizontal, vertical)
|
||||
canvas.scale(h ? -1 : 1, v ? -1 : 1);
|
||||
canvas.translate(h ? -w : 0, v ? -h : 0);
|
||||
canvas.drawImage(image, 0, 0);
|
||||
114 export function applyPhotoRotation(image, quarter = 0, straighten = 0, flipH = false, flipV = false)
|
||||
turn (rotateSkImage90 × turns) → straighten (rotateSkImageBy) → flip
|
||||
```
|
||||
|
||||
Comment web: *"a flip is what the user sees, so it mirrors the photo as it stands,
|
||||
whatever turn and angle are already on it"* ⇒ flip áp **sau** turn + straighten.
|
||||
`rotateSkImage90` đổi chiều rộng/cao; flip **không** đổi dims.
|
||||
|
||||
### 10.2 Hiện trạng Android
|
||||
|
||||
`src/utils/skiaImage.ts:69-73` — `applyPhotoRotation(image, quarter = 0, straighten = 0)`,
|
||||
không flip. Callers phải sửa:
|
||||
- `src/utils/exportEngine.ts:241`
|
||||
- `src/components/Viewfinder.tsx:461` (memo) và `:803` (`detectTilt(applyPhotoRotation(src.image, src.quarter, 0))`)
|
||||
|
||||
### 10.3 Việc phải làm
|
||||
|
||||
1. `src/utils/skiaImage.ts` — thêm `flipSkImage` + hai tham số `flipH`/`flipV` cho
|
||||
`applyPhotoRotation`, copy nguyên web (gồm `dispose()` các bản trung gian).
|
||||
2. State: `photoFlipH`/`photoFlipV` (boolean) cạnh `photoRotation`/`photoStraighten`
|
||||
(`App.tsx:104-105`); thêm vào `Look` (`719-720`), `lookNow()` (`728`), `applyLook`
|
||||
(`804-812`), "look đã đổi" (`699-700`), và `ExportOptions` (`exportEngine.ts:91-92`).
|
||||
3. Truyền xuống: options export (`App.tsx:1753-1754`), `Viewfinder` (`App.tsx:1983-1984`
|
||||
và `2100-2101`), `nativeExport`.
|
||||
4. RESET của strip ROTATE phải reset **cả hai** flip (như đang reset turn + angle —
|
||||
`AdjustmentPanel.tsx:727-729`).
|
||||
5. UI: `groupDefs.rotate.options` (`AdjustmentPanel.tsx:718-726`) thêm
|
||||
`{ v: 'fliph', d: 'H-FLIP' }` và `{ v: 'flipv', d: 'V-FLIP' }`; `onPick`
|
||||
(`730-745`) toggle cờ. `rotateChip()` (`791-803`) thêm phần `H-FLIP`/`V-FLIP` vào
|
||||
`parts` và vào `amberValue`.
|
||||
**Lưu ý:** strip ROTATE hiện là radio (một `value`), `value` đang là
|
||||
`openParam === 'straighten' ? 'straighten' : String(photoRotation)` (`716`). Thêm hai
|
||||
toggle thì `value` phải ưu tiên `fliph`/`flipv` khi cờ bật — cùng cách `straighten`
|
||||
đang chiếm `value`.
|
||||
|
||||
### 10.4 Nghiệm thu
|
||||
|
||||
- `H-FLIP` ↔ `V-FLIP`: mirror đúng trục (kiểm 4 pixel góc đối xứng).
|
||||
- `H-FLIP + V-FLIP` **pixel-identical** với `180°` (diff = 0 trên ảnh không đối xứng).
|
||||
- Dims **không** đổi sau flip (kể cả khi đang có turn 90 và straighten ≠ 0).
|
||||
- Crop vẫn hợp lệ: `cropRect` áp sau rotate+flip, không tràn khung.
|
||||
- Export == preview.
|
||||
- Sống qua reload/session và qua undo (một lần bấm = một bước undo).
|
||||
|
||||
---
|
||||
|
||||
## 11. Kiểm thử chung và parity gate
|
||||
|
||||
1. **Parity gate ba đường** (theo `PLAN.md`): cùng ảnh + cùng recipe → export JS,
|
||||
preview library (ảnh tĩnh), và Kotlin (nếu `EXPO_PUBLIC_NATIVE_EXPORT=1`) phải khớp
|
||||
(đo diff pixel, ngưỡng đã dùng trước đây trong repo).
|
||||
2. **Không regress:** một recipe cũ (chỉ matrix + tone + grain, không mask/heal/curve/flip)
|
||||
phải render **y hệt** trước và sau khi port. Đây là test hồi quy bắt buộc chạy đầu tiên.
|
||||
3. **Bảng số của doc 4** phải tái lập được (mục 3.4).
|
||||
4. **Idempotence:** AUTO bấm hai lần ra cùng EV; ROTATE `AUTO` (straighten) bấm hai lần
|
||||
ra cùng góc (đã có).
|
||||
5. **Undo:** mỗi knob mới (whites/blacks/curve/mask/heal/mosaic/flip) đều phải undo được
|
||||
một bước và redo khớp.
|
||||
6. **Session:** mọi field mới sống qua reload (đi qua `Look`/session serialise).
|
||||
7. **Perf:** đo fps preview library sau khi thêm pass; mask + heal + mosaic + curve cùng
|
||||
lúc là trường hợp nặng nhất. Nếu tụt quá ngưỡng, dùng phương án rẻ của doc 4 §4.2
|
||||
(nhân gain bên trong SKSL) — nhưng **không** phá thứ tự `matrix → exposure → tone`.
|
||||
|
||||
---
|
||||
|
||||
## 12. Checklist thứ tự làm và rủi ro
|
||||
|
||||
Thứ tự cố định — mỗi bước phải xanh test trước khi sang bước sau:
|
||||
|
||||
| Bước | Việc | Phụ thuộc | Rủi ro |
|
||||
|------|------|-----------|--------|
|
||||
| A | §3 tách EV + `EXPOSURE_SKSL` + **áp doc 4** (`gradeThrough`, bỏ ma trận lần hai ở HDF) | — | Vuốt: đây là thay đổi chạm mọi ảnh. Làm riêng, có test hồi quy (11.2). |
|
||||
| B | §4 knee HIGHLIGHT (2 dòng) | A | Thấp; nhưng phải làm sau A mới thấy đúng. |
|
||||
| C | §5 WHITE/BLACK (uniform + paramDefs) | B | Thấp. Chỉ thêm uniform, không đổi layout cũ. |
|
||||
| D | §10 H-FLIP/V-FLIP | — | Thấp; chạm state/session nên phải kiểm reload + undo. |
|
||||
| E | §6 AUTO | A | Trung bình; `readPixels` tốn — đo thời gian, `dispose()` đủ. |
|
||||
| F | §7 TONE CURVE | A | Cao ở nhánh declarative (2 child shader). Fallback imperative. |
|
||||
| G | §8 GRADIENT MASK | A | Trung bình; uniform `origin` là điểm dễ sai toạ độ. |
|
||||
| H | §9 FIX (HEAL/MOSAIC) | G | Cao nhất: pixel sampler + `findHealSource` + brush UI mới. |
|
||||
|
||||
Rủi ro tổng:
|
||||
- **Hermes không thu hồi buffer native** — mọi surface/snapshot/image mới đều phải
|
||||
`dispose()` tường minh (xem comment đầu `exportEngine.ts:113-121`).
|
||||
- **Declarative preview không chèn pass tuỳ ý** — mask/heal/mosaic/curve đều phải nằm
|
||||
trong chuỗi shader của ảnh; nếu một pass không lồng được, chuyển nhánh preview tĩnh
|
||||
sang imperative.
|
||||
- **`RuntimeEffect` dựng lại mỗi frame** là bug hiệu năng kinh điển — cache theo count.
|
||||
|
||||
---
|
||||
|
||||
## 13. Những gì KHÔNG port
|
||||
|
||||
- **Per-mask invert / highlight-shadow weighting** — web hiện không có; không thêm.
|
||||
- **`grainSize`** — web-only ở thời điểm này.
|
||||
- **Chuột lăn đổi cỡ brush** (`wheelBrushR`) — Android dùng row slider SIZE.
|
||||
- **Halation** — không có trong phạm vi 7 hạng mục.
|
||||
- **`hslOn`/band HSL, `classic-vivid`, `mono-high-contrast`, `STOCK_BIAS.contrast`** —
|
||||
**đã** port xong trên nhánh này (mục 0.1). Không làm lại, chỉ điều chỉnh
|
||||
`STOCK_BIAS.exposure` của `leica-vivid` theo mục 3.3.
|
||||
@@ -0,0 +1,264 @@
|
||||
# 9 — RECIPE / QR: NHỮNG GÌ WEB VỪA SỬA VÀ APP ANDROID PHẢI KHỚP
|
||||
|
||||
Nhánh đích: `feat/vision-camera-v5` (base khi viết: **`f3d26e3`**). Ngày: 2026-09-29.
|
||||
Nguồn đối chiếu: nhánh **`recipes-web`**, commit **`3b92e4e`** — bản web đã chạy ở
|
||||
`http://127.0.0.1:8090`, bundle live `index-J8ISFv_z.js`.
|
||||
|
||||
Bốn commit web trong đợt này, tất cả ngày 2026-09-29:
|
||||
|
||||
```
|
||||
34f8601 studio: the develop column becomes five panels, and the four tone knobs move knots instead of channels
|
||||
c0aaa67 Studio: fold WB and FX into LIGHT, and put every develop slider on -100..+100
|
||||
bd57dd7 EXPOSURE reads in stops: -5..+5, two decimals, on an unchanged store
|
||||
3b92e4e A gradient mask can carry the LIGHT column's white balance now: COLOR TEMP and TINT
|
||||
```
|
||||
|
||||
Tài liệu 8 (`8_EDITOR_TOOLS_PORT.md`) đối chiếu web ở `6814b05`. Tài liệu này ghi **phần
|
||||
lệch phát sinh sau đó** và, quan trọng hơn cho đội Android, **cái gì trong file `.recipe`
|
||||
(QR) đã đổi / chưa đổi**. Lệnh đọc nguồn web vẫn như doc 8:
|
||||
|
||||
```sh
|
||||
git show origin/recipes-web:docker/frontend/shared/utils/gradientMask.ts
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 0. Bảng khoảng trống
|
||||
|
||||
| # | Web vừa đổi gì | Nguồn web | Ảnh hưởng `.recipe` | Android phải làm |
|
||||
|---|----------------|-----------|---------------------|------------------|
|
||||
| 1 | EXPOSURE đọc theo **stop** (−5..+5, 2 số lẻ) | `shared/utils/paramDefs.ts:53-66`, `shared/utils/colorUtils.ts:201` | **Store không đổi** (±10 unit, 1 unit = 0.25 EV) | Chỉ UI/đơn vị hiển thị. Recipe cũ đọc y nguyên |
|
||||
| 2 | Mọi slider "develop" thành **−100..+100** | `shared/utils/paramDefs.ts:40-43,160,184` | **Store không đổi** (±10) — ×10 là của slider | Không bắt buộc. Không port thì recipe vẫn khớp |
|
||||
| 3 | Rail **10 tab → 8 tab** (WB, FX gộp vào LIGHT) | `src/ui/ToolRail.tsx:15-27` | Không | Không (layout, không vào recipe) |
|
||||
| 4 | Bốn knob tone thành **knot của một ramp** + knee giữ hue | `shared/utils/toneShader.ts:109,196-221,242-247` | Không có field mới, nhưng **look đổi** | Port theo doc 8 §4/§5 (xem §3.3) |
|
||||
| 5 | Mask gradient mang **COLOR TEMP + TINT** | `shared/utils/gradientMask.ts:63-65,116-117,166-167,201,320`, `shared/types/index.ts:139-175,228` | **Có 2 field mới trong `adjustments.masks[]`** | Port mask (doc 8 §8) + 2 field + 1 array uniform `wb` |
|
||||
|
||||
Điểm đáng chú ý cho đội Android: **bốn trong năm thay đổi không đụng tới payload**. Chỉ mục 5
|
||||
thêm field. Nhưng mục 1 + 4 làm **cùng một recipe cho ra look khác** giữa hai bên — xem §3.3.
|
||||
|
||||
---
|
||||
|
||||
## 1. Đơn vị: slider web vs store (và vs Android)
|
||||
|
||||
Web tách hẳn **đơn vị slider** khỏi **đơn vị store**: `paramDefs.ts` có `get`/`set` dịch hai
|
||||
chiều, nên một recipe viết trước đợt này vẫn đọc ra đúng look.
|
||||
|
||||
```ts
|
||||
// shared/utils/paramDefs.ts:40-43
|
||||
const HUNDRED = new Set(['contrast', 'color', 'vibrance', 'highlight', 'shadow', 'whites',
|
||||
'blacks', 'tint', 'denoise', 'clarity', 'dehaze', 'sharpening']);
|
||||
const deepen = (def) => HUNDRED.has(def.key)
|
||||
? { ...def, min: -100, max: 100, get: (a) => def.get(a) * 10, set: (v) => def.set(v / 10) }
|
||||
: def;
|
||||
```
|
||||
|
||||
```ts
|
||||
// shared/utils/paramDefs.ts:53-66
|
||||
{ key: 'exposure', min: -5, max: 5, step: 0.01, display: twoStops, // '+0.50' / '-0.25'
|
||||
get: (a) => (a.exposure ?? 0) * EV_PER_UNIT,
|
||||
set: (v) => ({ exposure: Math.round((v / EV_PER_UNIT) * 1e4) / 1e4 }) }
|
||||
```
|
||||
|
||||
| Trường | Đơn vị store (web **và** Android) | Slider web (`3b92e4e`) | Slider Android (`src/utils/paramDefs.ts`) |
|
||||
|--------|-----------------------------------|------------------------|-------------------------------------------|
|
||||
| `adjustments.exposure` | ±10 unit, 1 unit = **0.25 EV** (`EV_PER_UNIT`, `colorUtils.ts:201`) | −5..+5 **stop**, 2 số lẻ | −10..+10, step 1, `display: sign` (`paramDefs.ts:32-41`) |
|
||||
| `contrast` `highlight` `shadow` `whites` `blacks` `tint` `denoise` `clarity` `dehaze` `sharpening` `vibrance` `color` | ±10 | −100..+100 (`HUNDRED` + `deepen`) | ±10, step 1 |
|
||||
| `temperature` | 2500..10000 K | 2500..10000, step 100, `'${v}K'` (`paramDefs.ts:163-172`) | 2500..10000 (đã khớp) |
|
||||
| `masks[].exposure` | **chính là EV**, ±5 (`MASK_EXPOSURE_MAX`, `gradientMask.ts:45`) | −5..+5, step 0.1 | — (chưa có mask) |
|
||||
| `masks[].temperature` | 2500..10000 K, **default 5500** (`NEUTRAL_K`, `gradientMask.ts:63`) | 2500..10000, step 100 | — |
|
||||
| `masks[].tint` | ±10 (default 0) | −100..+100 (maskKnobRow ×10, `App.tsx:2474-2492`) | — |
|
||||
|
||||
**Hệ quả cho Android:** ba dòng đầu **không cần làm gì để recipe khớp** — store giữ nguyên
|
||||
thang cũ. Chỉ nên đổi UI nếu muốn hai bên trông giống nhau (Android `EXPOSURE` ±10 unit =
|
||||
±2.5 EV, web đọc thành −5..+5 stop; cùng một con số lưu, khác nhãn). Chú ý `EV_PER_UNIT`
|
||||
**đã export** ở cả hai phía — dùng nó, đừng viết lại `0.25`.
|
||||
|
||||
Chú ý riêng: `masks[].exposure` **không** theo thang ±10 của `adjustments.exposure`. Nó là
|
||||
EV trực tiếp vì shader đọc `pow(2.0, a.x)`. Đây là chỗ dễ sai nhất khi port.
|
||||
|
||||
---
|
||||
|
||||
## 2. Cái gì thực sự đi qua file `.recipe` / QR
|
||||
|
||||
Không đổi gì so với `7_SCAN_QR.md`: **QR chỉ chứa URL** `GET /api/photos/:id/preset.recipe`,
|
||||
file trả về là XML bọc payload xor16-v1 (`docker/backend/src/recipeFile.ts`, bản sao của
|
||||
`shared/utils/recipeShare.ts`; nguồn sự thật là bản web).
|
||||
|
||||
Hình dạng shareable — **web và Android giống hệt nhau, từng chữ**:
|
||||
|
||||
```ts
|
||||
// recipeShare.ts:59-77 (web) == src/utils/recipeShare.ts:59-77 (Android)
|
||||
const shareable = { name, baseFilter, adjustments, frameId, useGeotag };
|
||||
```
|
||||
|
||||
`adjustments` được gửi **nguyên khối**. Nghĩa là:
|
||||
|
||||
1. **Field mới tự chảy qua.** Web ghi `adjustments.masks[].temperature/tint`; không cần bump
|
||||
`version="1"`, không cần đổi `ALGORITHM`, không cần salt mới.
|
||||
2. **Android hiện giữ được field lạ.** `importRecipeXml` làm
|
||||
`{ ...DEFAULT_ADJUSTMENTS, ...raw.adjustments }` rồi **chỉ** sanitize `hslBands`
|
||||
(`recipeShare.ts:97-102`). `masks` vào bộ nhớ, sống qua `Look`/session, và **xuất lại
|
||||
nguyên vẹn** vì `exportRecipeXml` gửi lại chính `recipe.adjustments`.
|
||||
⇒ Web → Android → Web **không mất mask**. Đây là hành vi phải **khoá bằng test**, không
|
||||
phải tính năng tình cờ (xem §4.2).
|
||||
3. **Nhưng Android không render mask** (`grep -n masks src/types/index.ts` → rỗng;
|
||||
`ColorAdjustments` `src/types/index.ts:81-105` không có `masks`). Ảnh xuất từ Android vì
|
||||
thế **thiếu toàn bộ mask** dù recipe có. Đó là việc của doc 8 §8, không phải lỗi format.
|
||||
4. `RecipeCreateModal.handleSave` spread `...seedAdj`, nên mở một recipe import được rồi bấm
|
||||
LƯU **không** làm rơi `masks`. Phải giữ đúng như vậy sau khi thêm field (đừng liệt kê tay
|
||||
từng knob).
|
||||
5. Web clamp khi **đọc** (`readMasks`, `gradientMask.ts:87-121`), không clamp khi ghi. Android
|
||||
cũng nên clamp ở `readMasks` tương đương, không nhét clamp vào `importRecipeXml`.
|
||||
|
||||
---
|
||||
|
||||
## 3. Việc Android phải làm
|
||||
|
||||
### 3.1 Tách `whiteBalanceGain` (thuần refactor, kết quả không đổi)
|
||||
|
||||
Android **đã có** công thức WB frame-wide, viết inline trong `getSkiaColorMatrix`:
|
||||
|
||||
```
|
||||
src/utils/colorUtils.ts:384-394
|
||||
const rgbTemp = kelvinToRGB(temperature);
|
||||
const tintMagenta = (tint / 10) * 0.08;
|
||||
const wbGain = normalizeGainLuma([
|
||||
rgbTemp.r * (1 + tintMagenta),
|
||||
rgbTemp.g * (1 - tintMagenta),
|
||||
rgbTemp.b * (1 + tintMagenta),
|
||||
]);
|
||||
```
|
||||
|
||||
Web đã rút đúng khối này ra thành một hàm export, **vì nó được hỏi hai lần** (frame-wide và
|
||||
từng mask). Việc cần làm: chép y nguyên chữ ký web, cho `getSkiaColorMatrix` gọi lại nó.
|
||||
|
||||
```ts
|
||||
// shared/utils/colorUtils.ts:130-137 (web) — chép sang src/utils/colorUtils.ts
|
||||
export function whiteBalanceGain(temperature: number, tint: number): { r: number; g: number; b: number } {
|
||||
const rgbTemp = kelvinToRGB(temperature); // kelvinToRGB đã có ở Android:117
|
||||
const tintMagenta = (tint / 10) * 0.08;
|
||||
return normalizeGainLuma([ // đã có ở Android:147
|
||||
rgbTemp.r * (1 + tintMagenta),
|
||||
rgbTemp.g * (1 - tintMagenta),
|
||||
rgbTemp.b * (1 + tintMagenta),
|
||||
]);
|
||||
}
|
||||
```
|
||||
|
||||
Gain tại 5500 K / tint 0 = `(1, 1, 1)` chính xác (`kelvinToRGB(5500) === kelvinToRGB(5500)`,
|
||||
rồi `normalizeGainLuma`). `KELVIN_TAME = 0.5` nằm trong `kelvinToRGB`, không đụng.
|
||||
|
||||
**Nghiệm thu bắt buộc:** `getSkiaColorMatrix` trước và sau refactor phải ra **cùng mảng 20
|
||||
số** với mọi cặp `(baseFilter, temperature, tint)` trong lưới thử (kể cả `wbRed/wbBlue`, kể
|
||||
cả stock mono — nhánh `isMonochromeBase` phải bỏ qua WB **trước khi** gọi hàm).
|
||||
|
||||
### 3.2 Mask gradient (doc 8 §8) + hai field mới
|
||||
|
||||
Port theo doc 8 §8, thêm đúng ba điểm so với spec cũ:
|
||||
|
||||
1. `GradientMask` thêm `temperature?: number` (2500..10000) và `tint?: number` (±10) —
|
||||
giống `shared/types/index.ts:174-175` (comment web ở `:170-173` giải thích: cùng đơn vị,
|
||||
cùng gain, chỉ khác vùng đọc).
|
||||
2. `readMasks` resolve default: `temperature = clampK(num(m.temperature, 5500))`,
|
||||
`tint = clampA(num(m.tint, 0))` (`gradientMask.ts:116-117`) — mask lưu trước đó ⇒ gain 1.
|
||||
3. Uniform: thêm **một mảng `float4` nữa**, `wb[count]`, **sau** `fx[count]`; buffer thành
|
||||
`6*n + 1 (+1 nếu air)` float4, mảng `wb` ở index `5*n + i`, `size` (width/height) ở `6*n`,
|
||||
`air` ở `6*n+1` (`gradientMask.ts:132-171, 314-322`).
|
||||
|
||||
Shader — một dòng, ngay sau EXPOSURE, trước CONTRAST:
|
||||
|
||||
```sksl
|
||||
half3 maskAdjust(half3 c, half3 wb, float4 a, float4 tone, float4 fx, half dark /*, half3 blur, float3 air*/) {
|
||||
c = c * half(pow(2.0, a.x)); // EXPOSURE, EV
|
||||
c = clamp(c * wb, half3(0.0), half3(1.0)); // ← MỚI: WB của mask
|
||||
c = (c - half(0.5)) * half(1.0 + a.y) + half(0.5);
|
||||
...
|
||||
```
|
||||
|
||||
`wb` là gain **hoisted lên JS**, không tính trong SkSL: fit Kelvin là một đường cong, viết lại
|
||||
trong shader là bản sao thứ hai của nó. Call site: `maskAdjust(c.rgb, wb[i].rgb, adj[i], tone[i], fx[i], dark)`.
|
||||
|
||||
**Chiều đã đo trên web** (đừng đảo): 2500 K = **lạnh**, 10000 K = **ấm**. Trong mask, đặt
|
||||
10000 K đo được R/B `1.250 → 1.724`; pixel ngoài mask `0.994 → 0.994` (không đổi). Đó là
|
||||
bằng chứng mask-ảnh-hưởng-cục-bộ phải tái lập được.
|
||||
|
||||
### 3.3 Cảnh báo lệch: tone **trong mask** chưa bằng tone frame-wide
|
||||
|
||||
Đây là điểm dễ hiểu sai nhất khi đọc song song doc 8 và bản web mới nhất.
|
||||
|
||||
- Frame-wide (`shared/utils/toneShader.ts:242-247`, từ `c0aaa67`): knee giữ hue, không còn
|
||||
`cg = clamp(o / max(t, 0.0004), 0.55, 1.35)`:
|
||||
```sksl
|
||||
float k = 1.0;
|
||||
float hiC = max(max(rgb.r, rgb.g), rgb.b);
|
||||
float loC = min(min(rgb.r, rgb.g), rgb.b);
|
||||
if (hiC > t) k = min(k, (1.0 - o) / (hiC - t));
|
||||
if (loC < t) k = min(k, o / (t - loC));
|
||||
rgb = clamp(vec3(o) + (rgb - vec3(t)) * k, 0.0, 1.0);
|
||||
```
|
||||
và bốn knob tone là **knot của một ramp** (`TONE_ANCHOR = 0.25`; `blMask/shMask/whMask/hlMask`
|
||||
tại `:196-199`; `a0..a4` tại `:214-221`).
|
||||
- Trong mask (`shared/utils/gradientMask.ts:218-220`): **vẫn công thức cũ**
|
||||
`half cg = clamp(lifted / max(l, 0.0004), 0.55, 1.35); c = clamp(lifted + (c - l) * cg, …)`,
|
||||
và mask **không** có knot ramp. Comment ngay trên đó (`:207-213`) vẫn nói "TONE_SKSL's own
|
||||
cg" — **comment đã cũ** sau `c0aaa67`.
|
||||
|
||||
⇒ Trên web hiện tại, HIGHLIGHT của một mask **không** phải cùng một phép move như HIGHLIGHT
|
||||
toàn khung, đúng cái điều comment hứa. Đã hỏi chủ dự án có align không — chưa có quyết định.
|
||||
Cho tới lúc đó:
|
||||
|
||||
- **Android port mask theo bản web đang chạy** (công thức `cg` cũ trong mask), nếu mục tiêu là
|
||||
parity với web **hôm nay**.
|
||||
- Nếu chủ dự án chốt align, việc cần làm là thay khối `:218-220` bằng công thức `k` giữ hue —
|
||||
và khi đó **mask phải chạy trước** phép move tone nào khác của khung (`masks` đứng **sau**
|
||||
tone frame-wide trong chuỗi pass, xem doc 8 §2.1). Ghi lại ở đây để cả hai bên cùng đổi một
|
||||
lần, tránh web sửa trước rồi Android port theo bản cũ.
|
||||
|
||||
### 3.4 Port tối thiểu nếu chỉ muốn "giữ field, chưa render"
|
||||
|
||||
Nếu ưu tiên là **không mất dữ liệu khi chia sẻ QR** trước khi kịp port shader mask, chỉ cần:
|
||||
|
||||
```ts
|
||||
// src/types/index.ts — đủ để TS không nuốt field khi ai đó truy cập
|
||||
masks?: GradientMask[];
|
||||
```
|
||||
|
||||
Ở runtime Android **đã** giữ field rồi (§2.2), nên thay đổi này là để code đọc/ghi tường minh,
|
||||
không phải để cứu dữ liệu. **Không** thêm sanitize cho `masks` ở giai đoạn này — sanitize nửa
|
||||
vời sẽ âm thầm cắt dữ liệu mà web vẫn gửi (ví dụ bỏ mask chưa có `temperature`).
|
||||
|
||||
---
|
||||
|
||||
## 4. Tiêu chí nghiệm thu
|
||||
|
||||
1. **Round-trip QR, không mất field.** Recipe web có mask + WB (ví dụ 8000 K / tint −3):
|
||||
web xuất `.recipe` → `importRecipeXml` Android → `exportRecipeXml` Android → import lại
|
||||
web. So `adjustments.masks` sau khi bỏ salt: phải **bằng byte** bản gốc, gồm cả
|
||||
`temperature`, `tint`, `kind`, `angle`, `feather`, `exposure` (EV), và thứ tự các mask.
|
||||
2. **Không có test nào phụ thuộc vào việc "field lạ bị bỏ".** Thêm một test khoá hành vi §2.2
|
||||
(một field lạ bất kỳ trong `adjustments` phải sống qua import → export).
|
||||
3. **Clamp đúng biên.** Mask gửi `temperature: 99999` ⇒ đọc ra 10000; `2000` ⇒ 2500; `tint: 40`
|
||||
⇒ 10. Mask **không** có hai field ⇒ gain `(1,1,1)` và ảnh **không đổi một pixel** so với
|
||||
trước khi port.
|
||||
4. **Refactor WB trung tính.** `getSkiaColorMatrix` ra cùng 20 số trước/sau §3.1, trên lưới
|
||||
`(baseFilter × temperature × tint)` gồm cả stock mono.
|
||||
5. **WB của mask là cục bộ.** Đặt 10000 K: pixel **trong** mask đổi tỉ lệ R/B (ấm lên), pixel
|
||||
**ngoài** mask **không đổi** (web đo `1.250 → 1.724` trong mask, `0.994 → 0.994` ngoài mask).
|
||||
6. **Đơn vị EV của mask.** `masks[].exposure = +1` phải sáng đúng **1 stop** (không phải 1/4
|
||||
stop như nếu lỡ dùng thang ±10 của `adjustments.exposure`).
|
||||
7. **Parity ba đường** như doc 8 §11.1, và **look cũ không regress** (recipe chỉ có matrix +
|
||||
tone + grain phải render y hệt trước/sau).
|
||||
8. **Tone:** nếu chủ dự án chốt align tone trong mask (§3.3), phải có số đo hue trước/sau
|
||||
tương đương bảng trong commit `c0aaa67` — HIGHLIGHT −100 Δhue `0.00°` trên mọi patch.
|
||||
|
||||
---
|
||||
|
||||
## 5. Những gì KHÔNG làm
|
||||
|
||||
- **Không đổi `version` / `ALGORITHM` / salt** của envelope `.recipe`. `test/security.mjs`
|
||||
(backend 134/134) khoá hình dạng này.
|
||||
- **Không nhét mask vào QR**: mã QR vẫn chỉ là URL (§2). Recipe nằm ở nội dung file tải về.
|
||||
- **Không commit đi theo đợt này:** `preview`/`mask` UI của web chỉ đổi layout, không có gì
|
||||
để port sang app.
|
||||
- **Không port thang −100..+100** sang Android chỉ để "giống web" — đó là lựa chọn UI, store
|
||||
hai bên đã khớp; đổi thì phải đổi cả `paramDefs`, cả chip readout, cả test ảnh.
|
||||
@@ -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ỏ.
|
||||
@@ -1,6 +1,6 @@
|
||||
The MIT License (MIT)
|
||||
|
||||
Copyright (c) 2015-present 650 Industries, Inc. (aka Expo)
|
||||
Copyright (c) 2026 Lộc Phạm
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
|
||||
@@ -0,0 +1,750 @@
|
||||
# PLAN — 7 hạng mục UI: PHOTO STYLE · tên sim · WATERMARK→FRAME · TEMP · FAVORITED · RESET* · ROTATE
|
||||
|
||||
Nhánh: `feat/vision-camera-v5` (RecipesCam, `C:/Users/locpham/RecipesCam` ↔ `/home/locpham/RecipesCam`)
|
||||
Ngày: 2026-09-09. Trạng thái: **HOÀN TẤT cả 7 hạng mục** — F2 `6adf0b5`, F1 `d66ae3d`, F4 `7ed4301`, F6 `ba7e579`, F5 `ba971f1`, F3 `4131c9f` + `dcb74a7`, đổi tên chip frame `6788d51`, F7 `9ddaeac` + `0ce561c`. Mỗi commit đều qua typecheck (14), `assembleRelease` và verify trên `emulator-5554`; `git status --short` sạch (chỉ còn file plan này, untracked). **Follow-up 4 lỗi hành vi strip ROTATE đã sửa, verify trên máy và commit `da48cec` — xem mục 12.** **Toàn bộ câu hỏi mục 10 đã chốt** (Q1 `CLASSIC CHRIPES` giữ nguyên, Q4 chip không kèm tên sim, Q6 strip `RESET · AUTO · 0 · 90 · 180 · 270 · STRAIGHTEN (±45°)` — AUTO ngay sau RESET, Q7 ★ chỉ recipe).
|
||||
Đây là plan riêng, **không** gộp vào `PLAN.md` (đang là kế hoạch native export engine).
|
||||
|
||||
Baseline typecheck: `node --stack-size=16000 ./node_modules/typescript/lib/tsc.js --noEmit` → **14 lỗi nền** (không được tăng).
|
||||
|
||||
---
|
||||
|
||||
## 0. Hiện trạng (mốc trong code)
|
||||
|
||||
| Thứ | Nơi |
|
||||
|---|---|
|
||||
| Tabs + nhãn | `src/components/ToolRail.tsx:5` (`TabId`), `:15-22` (`TOOLS`), `:26` (FRAME chỉ ở library) |
|
||||
| Chip theo tab | `src/components/AdjustmentPanel.tsx:492` `case 'recipes'`, `:548` `case 'wb'`, `:587` `case 'frame'`, `:617` `case 'watermark'` |
|
||||
| Cơ chế strip 1-hàng | `GroupKey` `:22`, `openGroup` `:217`, `groupDefs` `:415`, `groupChip` `:481`, `choiceChips` `:321` |
|
||||
| `renderChip` (đã có `amberValue`, `trash`, `disabled`) | `:365` |
|
||||
| Sims | `src/utils/defaultRecipes.ts:39-52` (`FILM_SIMS`), `filmSimToRecipe` `:55` |
|
||||
| WB presets | `AdjustmentPanel.tsx:118` (`WB_PAIRS`), `wbActive()`/`wbApply` `:~380` |
|
||||
| Recipes lưu trữ | `src/utils/storageUtils.ts` (`@camrecipe_pro:custom_recipes`) |
|
||||
| `Recipe` type | `src/types/index.ts:87` |
|
||||
| Reset | `App.tsx:506` `handleResetAll`, prop `onResetAll` (`App.tsx:1277`) |
|
||||
| Watermark state | `App.tsx:86-102` (`DEFAULT_CUSTOM_WM`, `wmRotation`, `DEFAULT_GPS_WM`), `wmEditing` `App.tsx:1235` |
|
||||
| Xoay ảnh (sau F7) | `App.tsx`: `photoRotation: 0\|90\|180\|270` + `photoStraighten` + `autoHorizon`/`horizonRoll`; strip `AdjustmentPanel.tsx` `groupDefs.rotate`; `rotateSkImage90` + `rotateSkImageBy` `src/utils/skiaImage.ts`, dùng ở `exportEngine.ts` + `Viewfinder.tsx` |
|
||||
| Frame | `src/utils/frameUtils.ts:9` (`FRAMES`), `App.tsx:547` `handleUpdateFrame` |
|
||||
|
||||
Quy ước chung: file **CRLF** → sửa bằng python (`io.open(p, encoding='utf-8').read()` … ghi `newline='\r\n'`) hoặc `edit_file` cẩn thận; mọi lệnh shell prefix `export MSYS_NO_PATHCONV=1`; `PYTHONIOENCODING=utf-8` khi có non-ASCII.
|
||||
|
||||
---
|
||||
|
||||
## 1. F1 — Gom sim vào chip `PHOTO STYLE`
|
||||
|
||||
**Việc:** trong tab PRESETS, 8 chip sim (`FILM_SIMS.map`, `AdjustmentPanel.tsx:497`) không còn nằm trực tiếp trên hàng chip mà nằm trong strip của một group chip `PHOTO STYLE`.
|
||||
|
||||
**Cách làm**
|
||||
1. `GroupKey` thêm `'style'`; `groupDefs.style`:
|
||||
- `label: 'PHOTO STYLE'`, `off: 'sim-provia'`,
|
||||
- `value`: id sim đang chọn (`currentRecipeId` nếu là sim, ngược lại `''`),
|
||||
- `options`: `FILM_SIMS.map(s => ({ v: s.id, d: s.name }))`,
|
||||
- `onPick`: `setShowRecipes(false); onSelectRecipe(filmSimToRecipe(sim))` (giữ nguyên hành vi cũ).
|
||||
2. `case 'recipes'` (`:492`): thay khối `...FILM_SIMS.map(...)` bằng `renderChip(groupChip('style'))`; giữ `RECIPES` + `CREATE`.
|
||||
3. Nhãn chip: **chỉ `PHOTO STYLE`**, không kèm tên sim (đã chốt — TopBar góc trái đã hiện `activeRecipe.name`). `groupDefs.style.value` vẫn dùng để chip sáng amber khi sim ≠ mặc định, nhưng không in value ra nhãn.
|
||||
4. `amberValue` bật khi sim đang chọn ≠ sim khởi động (`sim-provia`).
|
||||
|
||||
**Kiểm chứng:** PRESETS chỉ còn `RESET · PHOTO STYLE · RECIPES · CREATE`; tap `PHOTO STYLE` → strip 8 sim; tap một sim → áp đúng look, chip đổi nhãn; luật "1 strip/lần" giữ; `RECIPES`/`CREATE` không đổi.
|
||||
|
||||
---
|
||||
|
||||
## 2. F2 — Đổi tên sim
|
||||
|
||||
`src/utils/defaultRecipes.ts:40-51`:
|
||||
|
||||
| id | tên mới |
|
||||
|---|---|
|
||||
| `sim-provia` | `PROVIPES` |
|
||||
| `sim-velvia` | `VELVIPES` |
|
||||
| `sim-astia` | `ASTIPES` |
|
||||
| `sim-classic-chrome` | `CLASSIC CHRIPES` |
|
||||
| `sim-classic-neg` | `CLASSIC NEGIPES` |
|
||||
| `sim-eterna` | `ETERNIPES` |
|
||||
| `sim-acros` | `ACRIPES` |
|
||||
| `sim-leica` | `LEITZ STREETLIFE` |
|
||||
|
||||
- **Chỉ đổi `name`**, giữ `id` + `baseFilter` → phiên đã lưu (`selectedRecipeId`) và các `find(f => f.id === 'sim-provia')` (`App.tsx:349,507`) không gãy.
|
||||
- Tên tự lan ra: chip PHOTO STYLE, TopBar (`activeRecipe.name`), strip recipe, picker simulation trong `RecipeCreateModal.tsx:330`.
|
||||
- Dọn nhắc tên cũ trong text UI: `SettingsModal.tsx:135` ("camera + PROVIA …"); comment `App.tsx:194,332`, `RecipeCreateModal.tsx:27,152`.
|
||||
- Recipe **bundled cũng đổi tên theo** (đã chốt): `defaultRecipes.ts` — `PROVIA STD` → `PROVIPES STD` (`:117`), `VELVIA VIVID` → `VELVIPES VIVID` (`:93`), `ACROS MONO` → `ACRIPES MONO` (`:141`), `CLASSIC NEG.` → `CLASSIC NEGIPES.` (`:69`). **Chỉ đổi `name`, giữ `id`** (`provia-default`, `velvia-default`, `acros-default`, `classic-neg-default`) → recipe đã lưu trong AsyncStorage vẫn trỏ đúng object.
|
||||
- **Không** đổi: `RETRO AMBER 🏮` (`:165`) và `CINEMA SPRING/SUMMER/AUTUMN/WINTER` (`:190-265`) — không gắn sim nào.
|
||||
|
||||
**Kiểm chứng:** top bar + chip + modal theo tên mới; look (matrix) không đổi.
|
||||
|
||||
---
|
||||
|
||||
## 3. F3 — WATERMARK thành chip trong tab FRAME
|
||||
|
||||
**Chặn:** FRAME hiện chỉ có ở library (`ToolRail.tsx:26`) → ở camera watermark sẽ mất lối vào. Chọn (Q3):
|
||||
- **(A, đề xuất)** bỏ filter `:26` → FRAME có ở cả 2 mode; frame picker cũng dùng được ở camera (hiện camera chỉ nhận frame từ recipe).
|
||||
- (B) giữ FRAME library-only + camera vẫn có tab WATERMARK riêng → không đúng yêu cầu, loại.
|
||||
|
||||
**Kiểm chứng code — viewfinder camera có hiển thị đúng phần trong suốt của frame không? CÓ, không cần vẽ thêm gì.**
|
||||
- `Viewfinder.tsx:1937` `renderCameraOverlays()` được gọi trong nhánh camera (`:2102`), ngay trên `<SkiaCamera>` (`:2025`) — layer này là `StyleSheet.absoluteFill`, tức feed phủ full màn hình và nằm **dưới** overlay.
|
||||
- `:1974` `renderFrameOverlay()` gọi **chung** cho mọi frame, không phân nhánh theo mode:
|
||||
- `:1778` polaroid: chỉ vẽ bar đen ngoài card + card trắng quanh cửa sổ; **cửa sổ để trống** → feed lộ qua (comment `:1782-1787` nói đúng điều này).
|
||||
- `:1817` wallframe: bar đen ngoài frame rect + PNG mat qua `drawMatrix`; **cửa sổ trong suốt của PNG** cho feed lộ qua (cover-crop giữa, WYSIWYG với crop export — comment `:1821-1824`).
|
||||
- `:1839` classic-white / `:1852` cinematic: viền trắng / bar đen theo `frameRect` = `region` (band tỉ lệ) → đã đúng ở camera.
|
||||
- `polaroidGeo` `:1124`, `wallGeo` `:1131` **không** phụ thuộc mode (chỉ `selectedFrame` + `vw/vh`) → bản camera dùng đúng rect đó; hai nhánh library riêng (`:2162`, `:2242`) nằm **sau** `if (mode === 'camera') { … return }` (`:1995`) nên không ảnh hưởng.
|
||||
- `ToolRail.tsx:10-11` đã ghi sẵn lý do: FRAME library-only vì "camera shows every frame WYSIWYG through the viewfinder overlays".
|
||||
- Kết luận: **F3 thuần là việc UI** (cho tab FRAME hiện ở camera + đổi điều kiện `wmEditing`); export camera cũng đã dùng `frameId: selectedFrame` (`App.tsx:~906`, `~995`) nên không lệch WYSIWYG.
|
||||
- Hai điểm nhỏ, **không phải bug**, chỉ để biết: (1) band tỉ lệ chỉ áp cho none/classic-white/cinematic (`:255`) — polaroid/wallframe bỏ qua ratio ở camera đúng như thiết kế; (2) wallframe đợi `wallImage` load mới vẽ (`:1819`) → thoáng một nhịp thấy feed trần.
|
||||
|
||||
|
||||
**Kiểm chứng code 2 — anchor/crop live feed vào đúng cửa sổ trong suốt: ĐÃ CÓ SẴN, không phải việc mới.**
|
||||
- `Viewfinder.tsx:1201` `cameraPhotoRect` = `polaroidGeo.window` → `wallGeo.window` → `region` (band tỉ lệ) → `null`; `:1211` đẩy rect đó (chuẩn hoá theo `vw/vh`) vào `feedRectSync` `:791` `[active, viewAspect, fx, fy, fw, fh]`.
|
||||
- Worklet `handleFrame` `:903-960`: khi `feed[0] > 0` thì black surface + **cover-map cảm biến vào ĐÚNG rect đó** (nghịch đảo CTM `renderToTexture` để lấy vùng raw frame), thay vì cover-fit fullscreen. Tức là cửa sổ trong suốt của overlay và vùng feed hiển thị dùng **cùng một rect** → khớp pixel, không cần anchor tay.
|
||||
- Khớp với export: `exportEngine.ts:155` crop tỉ lệ chạy **trước** bước frame, nên ảnh vào bước polaroid đã đúng aspect cửa sổ (`:462-500` `drawImageRect` lấp kín window); wallframe `:541-570` cover-crop giữa vào window 2:3. Camera capture truyền `aspect: aspectRatio === 'full' ? undefined : aspectRatio` (`App.tsx:~917`) → cùng công thức. Kết luận: **WYSIWYG ở cả 4 frame, 0 dòng code thêm.**
|
||||
- **Pinch/drag cửa sổ frame ở CAMERA — ĐÃ CHỐT thuộc F3** (bước 7). Hiện `zoomWindowRect()` `:1139-1140` trả `null` khi `mode !== 'library'` và `handleCapture` camera không truyền `frameWindowZoom` (`App.tsx:~917`) → không làm bước 7 thì ảnh polaroid/wall chụp từ camera luôn center-crop 1x (vẫn WYSIWYG, chỉ mất tự do bố cục).
|
||||
|
||||
**Cách làm (A)**
|
||||
1. `ToolRail.tsx`: xoá `{ id: 'watermark' }` khỏi `TOOLS` `:21`; xoá `'watermark'` khỏi `TabId` `:5` (grep hết chỗ dùng).
|
||||
2. `AdjustmentPanel.tsx` `case 'frame'` `:587`: thêm `renderChip(groupChip('wm'))` nhãn `WATERMARK` (+ ` ON` khi gps/custom đang bật), `amberValue` khi có watermark bật.
|
||||
3. Strip `wm` là **sub-panel**: khi `openGroup === 'wm'`, hàng chip chính được thay bằng hàng watermark (breadcrumb `‹ WATERMARK` + các chip hiện có ở `:617-660`: GPS ON/OFF, gpsColor/gpsName/gpsTime, CUSTOM ON/OFF, ROTATE, ACCEPT/CANCEL, wmColor/wmSize/wmFont). Giữ luật "1 strip/lần", không cần level-3.
|
||||
4. Ô nhập `TYPE WATERMARK TEXT` (`:764`, điều kiện `activeTab === 'watermark'`) → đổi thành "sub-panel watermark đang mở".
|
||||
5. `App.tsx`: lift cờ `wmPanelOpen` (state mới) + prop `wmPanelOpen`/`onToggleWmPanel`; `wmEditing={openTab === 'frame' && wmPanelOpen}` (`:1235`) để drag/pinch watermark trên ảnh (`Viewfinder.tsx:1280,1285,1526`) chỉ arm khi sub-panel mở.
|
||||
6. `useEffect` reset `openGroup` khi đổi tab (`:229`) đã có → giữ.
|
||||
7. **Pinch/drag nội dung trong cửa sổ frame ở CAMERA** (đã chốt; phần nặng nhất của F3):
|
||||
- `Viewfinder.tsx` `zoomWindowRect()` `:1138`: bỏ `if (mode !== 'library') return null` → camera cũng trả cửa sổ (`polaroidGeo.window` / `wallGeo.window`).
|
||||
- `feedRectSync` `:791` payload 6 → 9 số: `[active, viewAspect, fx, fy, fw, fh, s, u, v]` (`u,v` = tâm nguồn 0..1, cùng ngữ nghĩa `frameWindowZoom` của export); map trong worklet `:903-960` nhân scale + offset trước khi cover-map. Hook đẩy rect `:1211` truyền thêm `s,u,v` từ state zoom camera.
|
||||
- Clamp như `clampZoomToWindow` `:1176` để cửa sổ không lộ nền; đổi frame/ratio → reset `s=1, u=v=0.5`.
|
||||
- `App.tsx` nhánh camera `:917`: thêm `frameWindowZoom: viewfinderRef.current?.getFrameWindowZoom?.() ?? null` (đã có sẵn cho nhánh library `:~1011`, `Viewfinder.tsx:153/569`).
|
||||
- Cử chỉ: gesture layer camera `:2041-2048` đang là responder 1 chạm của `onImageDrag*` (AE/AF + kéo tham số) → chỉ nhận 2 ngón khi cửa sổ frame đang bật, và phải nhường ngón 1 cho AE/AF; **xung đột cần xử lý**: pinch cạnh tranh với zoom preset native (`applyZoom`, `:2125`) và kéo EV/AE.
|
||||
- Rủi ro: worklet + cử chỉ → **TB–cao**; verify riêng trên máy trước khi commit.
|
||||
|
||||
**Kiểm chứng:** camera + library đều thấy tab FRAME; chọn polaroid ở camera → vùng feed trong cửa sổ khớp đúng ảnh export (đối chiếu 1 ảnh chụp thật); pinch/drag được nội dung trong cửa sổ ở camera → ảnh export giữ đúng bố cục đã chọn (không lộ nền card/mat); ở camera chọn lần lượt `POLAROID`/`WALLFRAME`/`CLASSIC WHITE`/`CINEMATIC` → cửa sổ frame trong suốt, feed lộ đúng vị trí, không cần sửa lớp vẽ; FRAME → `WATERMARK` → strip watermark; bật CUSTOM → ô nhập hiện, kéo/thả watermark chạy; đóng panel → drag hết arm; RESET vẫn tắt mọi watermark.
|
||||
|
||||
**Đã làm (commit 1 `feat(frame): move the watermark controls into a FRAME sub-panel`) — 3 file:**
|
||||
- `ToolRail.tsx`: `TabId` bỏ `'watermark'`, `TOOLS` bỏ mục WATERMARK; bỏ prop `mode` + filter library-only của FRAME → rail giống nhau ở cả camera lẫn library.
|
||||
- `AdjustmentPanel.tsx`: `GroupKey` thêm `'wm'` (không phải strip thường, chỉ là cờ mở hàng phụ); `paramDefs` bỏ `watermark`; `case 'frame'`: `openGroup === 'wm'` → hàng **phụ** (`‹ FRAME` + GPS/CUSTOM/ROTATE/ACCEPT/CANCEL/COLOR/SIZE/FONT) **thay** hàng frame, ngược lại hàng frame + chip cuối `WATERMARK` (`active` khi sub-panel mở, `amberValue` khi có watermark bật); xoá hẳn `case 'watermark'` và các chip watermark cũ trong đó → chuyển thành `wmChip()`/`wmChips()`; ô nhập `TYPE WATERMARK TEXT` đổi điều kiện thành `activeTab === 'frame' && openGroup === 'wm' && customWm.enabled`; `useEffect(() => onWmPanelChange(openGroup === 'wm'), [openGroup])` để App biết lúc nào arm lớp drag của dấu.
|
||||
- `App.tsx`: state `wmPanelOpen`; `wmEditing={openTab === 'frame' && wmPanelOpen}` (thay `openTab === 'watermark'`); `onWmEdit` (double-tap trên dấu trong khung hình) đổi từ `setOpenTab('watermark')` → `setOpenTab('frame')`; truyền `onWmPanelChange={setWmPanelOpen}`.
|
||||
- Không đụng `Viewfinder.tsx`: đường vẽ watermark/WYSIWYG frame ở camera vốn đã đúng, F3 chỉ là chuyện điều hướng UI.
|
||||
|
||||
**Đã verify trên emulator-5554 (camera):**
|
||||
- Rail: `PRESETS · FAVORITED · LIGHT · WB · FX · FRAME` — không còn WATERMARK.
|
||||
- Hàng FRAME ở camera: `RESET · NO FRAME · CLASSIC BORDER · RETRO INSTANT · WALL FRAME · ROTATE n · WATERMARK` (CROP vắng mặt đúng thiết kế); `WATERMARK` → hàng phụ `RESET · ‹ FRAME · GPS WATERMARK OFF · CUSTOM WATERMARK ON · ROTATE n° · ACCEPT · CANCEL · TEXT COLOR · TEXT SIZE · TEXT FONT`; `‹ FRAME` quay lại đúng hàng frame.
|
||||
- WYSIWYG frame ở camera (quét pixel cột x=540 và hàng y=1000): RETRO POLAROID mat trắng `(250,249,246)`, cửa sổ x 55..1025, y ~430..1730, feed lọt trong đúng cửa sổ; WALL FRAME bar đen `(0,0,0)` + mat xám `(91,91,91)`; CLASSIC BORDER viền trắng `(255,255,255)`; NO FRAME feed ra tới mép.
|
||||
- Export giữ đúng bố cục: `camrecipe_pro_export_1789094424013.jpg` 960×1455, mat trắng `(250,249,245)` ngoài + nội dung camera trong; cửa sổ đo được 858×1146 → tỉ lệ **0,749** so với cửa sổ trên màn hình **0,746**.
|
||||
- Arm đúng cả hai chiều: sub-panel **đóng** + có chữ → kéo trên preview đổi EV `0.0 → +1.3` (diff toàn màn 1.146.032 px); sub-panel **mở** → kéo (540,1100)→(320,600) để EV **nguyên +1.3**, diff chỉ 8.203 px so với nền nhiễu feed tự nhiên 11.591 px.
|
||||
- **Watermark chữ tự nhập HIỆN đúng trong viewfinder camera**: đổi `TEXT COLOR → BLUE` rồi quét `#3b82f6` (±45/kênh) trong vùng feed → **571 px**, bbox `(543,1191)-(641,1212)`, ô lưới 60px tập trung ở (9,19)-(10,20) — đúng `wmAnchor()` (ax = 540, baseline ay ≈ 1212, glyph nằm trên baseline).
|
||||
- **Ghi chú phương pháp — kết luận "không thấy watermark" trước đó là do đo sai, không phải bug, cũng không phải regression của F3:** so 2 ảnh chụp (bật/tắt CUSTOM, hoặc đổi nội dung chữ) không kết luận được vì (a) hiện/ẩn ô nhập làm preview đổi chiều cao → diff toàn màn hình, (b) feed camera tự nhiễu ~1,6–2,7 % pixel ở ngưỡng 30–45, (c) chữ amber `#f59e0b` trên feed ấm không tách được khỏi nền. Cách đúng: chọn một màu không có trong cảnh (`BLUE`) và đếm pixel đúng màu trên **một** ảnh. Cũng xác nhận: dấu vẫn được vẽ khi sub-panel đã đóng (`wmEditing = false` chỉ ảnh hưởng việc arm, không ảnh hưởng việc vẽ).
|
||||
**Đổi tên chip frame (commit riêng `6788d51 feat(frame): rename the RETRO POLAROID chip to RETRO INSTANT`) — 3 file, chỉ 1 dòng code:**
|
||||
- `src/utils/frameUtils.ts`: `FRAMES` — `{ id: 'polaroid', name: 'RETRO INSTANT' }` (id giữ nguyên `polaroid` → session cũ + `frameId` đã lưu không gãy).
|
||||
- `src/components/Viewfinder.tsx` + `src/utils/exportEngine.ts`: chỉ sửa **comment** cho khớp tên mới (không đổi hành vi).
|
||||
|
||||
**Đã làm (commit 2 `feat(frame): pinch and drag the framed window on the camera`) — 2 file:**
|
||||
- `Viewfinder.tsx`: `zoomWindowRect()` bỏ `if (mode !== 'library') return null` → camera cũng trả `polaroidGeo.window` / `wallGeo.window`; band tỉ lệ vẫn trả band nhưng export không crop qua cửa sổ nên không đụng. `feedRectSync` payload 6 → 9 số `[active, viewAspect, fx, fy, fw, fh, z, u, v]`; effect đẩy rect đọc thêm `frameZoomRef.current` và phụ thuộc thêm `libZoom`.
|
||||
- Worklet `handleFrame`: chỉ khi `z > 1.001` mới crop — `k = s·z`, `canvas.translate(u0 − k·zwX, v0 − k·zwY)` + `scale(k)`. Tâm `(u,v)` là **toạ độ view** nên **phải** đi qua `toRaw()` giống rect cửa sổ; đưa thẳng vào mặt phẳng raw thì **đảo trục** khi sensor xoay (đúng lỗi đã gặp). `z = 1` → tâm ép về giữa, trùng nhánh cũ.
|
||||
- Cử chỉ camera: `onImageDragStart` seed `panRef`/`pinchRef`; `onImageDragMove` nhánh mới `g.kind !== 'wm' && !wmArmed && zoomWindowRect()` — 2 ngón → pinch quanh trung điểm + `clampZoomToWindow`; 1 ngón → chỉ khi `g.kind === 'focus'` **và** đã vượt `TAP_SLOP` mới pan (nhường ngón 1 cho AE/AF + kéo tham số); `onImageDragEnd` dọn cả ba ref.
|
||||
- `useEffect([mode, selectedFrame])`: ở camera → `setLibZoom({ s: 1, tx: 0, ty: 0 })` (đổi frame hoặc vào lại camera thì bắt đầu từ 1x giữa).
|
||||
- `App.tsx` nhánh capture camera: `const frameZoom = viewfinderRef.current?.getFrameWindowZoom?.() ?? null` đọc **lúc bấm shutter** (hàng đợi render chạy sau cú chụp kế tiếp nên không đọc lúc render), truyền `frameWindowZoom: frameZoom`.
|
||||
|
||||
**Kiểm chứng commit 2:** theo quy trình mục 9 (typecheck 14 → `assembleRelease` → install `emulator-5554`) rồi thử cử chỉ trong cửa sổ POLAROID / WALL FRAME ở camera (pinch 2 ngón đổi tỉ lệ, pan 1 ngón dịch nội dung, đều bị kẹp trong cửa sổ) và đối chiếu ảnh export với preview; 1 ngón ngoài cửa sổ vẫn là AE/AF + kéo EV như cũ.
|
||||
|
||||
|
||||
---
|
||||
|
||||
## 4. F4 — Gom WB vào chip `TEMP`
|
||||
|
||||
**Việc:** các chip `AUTO…FLUOR` (`:558-570`) **và** chip tham số `COLOR TEMP` (`paramDefs.wb[0]`) rời hàng chip, vào strip của group chip `TEMP`; chip `TEMP` hiển thị giá trị đang chọn: `TEMP AUTO`, `TEMP 5000K`, `TEMP FLUOR`.
|
||||
|
||||
**Cách làm**
|
||||
1. `GroupKey` thêm `'temp'`; `groupDefs.temp`:
|
||||
- `off: 'auto'`,
|
||||
- `value`: `wbChoice` ≠ null → preset đó; ngược lại `` `${temperature}K` ``,
|
||||
- `label: 'TEMP'` + value,
|
||||
- `options`: 7 preset + mục `COLOR TEMP` đặc biệt,
|
||||
- `onPick`: preset → `wbApply(v)`; `COLOR TEMP` → `toggleParam('temperature')` để mở hàng slider phía trên.
|
||||
2. `case 'wb'` `:548`: bỏ `choiceChips('wb', …)` và chip `temperature` khỏi `paramChips(paramDefs.wb)` (`TINT` + `WB SHIFT` vẫn ở hàng chính); thêm `renderChip(groupChip('temp'))`.
|
||||
3. `amberValue` khi WB ≠ neutral/AUTO.
|
||||
4. **Edge:** kéo slider COLOR TEMP phải `setWbChoice(null)` để chip rơi về `TEMP xxxxK` (hiện `wbChoice` chỉ set trong `wbApply`).
|
||||
|
||||
**Kiểm chứng:** WB chỉ còn `RESET · TEMP … · TINT · WB SHIFT · COLOR CHROME · CHROME BLUE`; `TEMP` → strip 7 preset + COLOR TEMP; chọn `FLUOR` → `TEMP FLUOR`; kéo slider → `TEMP 4000K`; chọn `AUTO` → `TEMP AUTO`, nhiệt 5500K/tint 0.
|
||||
|
||||
---
|
||||
|
||||
## 5. F5 — Tab `FAVORITED`
|
||||
|
||||
**Cách làm**
|
||||
1. `src/utils/storageUtils.ts`: key `@camrecipe_pro:favorites` — `getFavoriteIds(): Promise<string[]>`, `setFavorite(id, on)`. Dùng **danh sách id** (không dùng cờ trên `Recipe`) vì `DEFAULT_RECIPES` không persist → cờ trên object bundled sẽ mất. Dùng cho sim id, recipe bundled và recipe custom.
|
||||
2. `App.tsx`: state `favoriteIds: string[]`, hydrate cùng `recipes`; `handleToggleFavorite(id)` (optimistic + ghi AsyncStorage); truyền `favoriteIds` + handler xuống panel.
|
||||
3. `ToolRail.tsx`: thêm `{ id: 'favorites', label: 'FAVORITED' }` ngay sau `PRESETS` (rail là ScrollView ngang nên đủ chỗ).
|
||||
4. `AdjustmentPanel.tsx`: `case 'favorites'` — chip cho từng recipe yêu thích (`label` = `name`, `active` = đang chọn, `onPress` = `onSelectRecipe`), rỗng → chip disabled `NO FAVORITES YET - TAP ★ ON A RECIPE`; kèm nút bỏ yêu thích.
|
||||
5. Đánh dấu ★ **chỉ cho recipe** (đã chốt Q7 — sim không cần, `Recipe` đã lưu sim qua `baseFilter`): `renderChip` có tiền lệ nút phụ (`trash` `:373`) → thêm biến thể `star`/`starred` + `onPressStar`, dùng trên chip trong strip `RECIPES` và trong FAVORITED; **không** thêm ★ vào strip `PHOTO STYLE`.
|
||||
6. `resetEditsToRecipe` / `handleResetAll` **không** đụng `favoriteIds`.
|
||||
|
||||
**Kiểm chứng:** ★ một recipe → hiện trong FAVORITED; tắt app mở lại → còn nguyên; bỏ ★ → biến mất; tap chip → áp đúng look; tab rỗng hiện đúng empty state.
|
||||
|
||||
**Đã làm (commit `feat(panel): add a FAVORITED tab with a star on every recipe`)** — 4 file:
|
||||
- `src/utils/storageUtils.ts`: `FAVORITES_KEY = '@camrecipe_pro:favorites'`, `getFavoriteIds(): Promise<string[]>` (guard `Array.isArray` + lọc string, lỗi → `[]`) và `setFavoriteId(id, on)` (đọc lại rồi ghi, dedupe bằng `Set`).
|
||||
- `src/components/ToolRail.tsx`: `TabId` + `'favorites'`, chip `FAVORITED` ngay sau `PRESETS`.
|
||||
- `src/components/AdjustmentPanel.tsx`: `TabId` + `'favorites'`; `ChipDef` thêm `star`/`starred`/`onPressStar`; nhánh cuối của `renderChip` thành `c.trash || c.star` với **hai nút phụ độc lập** (recipe custom giữ cả ★ lẫn 🗑); `paramDefs.favorites = []`; `case 'favorites'` (chip = `recipes.filter(r => favoriteIds.includes(r.id))`, rỗng → chip disabled); ★ trên mọi chip của strip `RECIPES`; prop `favoriteIds` + `onToggleFavorite`.
|
||||
- `App.tsx`: state `favoriteIds: string[]`, hydrate trong `initSession`, `handleToggleFavorite(id)` (optimistic `setFavoriteIds` + `setFavoriteId(...).catch(warn)`), truyền xuống panel. ★ **không** nằm trong `handleResetAll`/`resetDirty` nên RESET không đụng tới.
|
||||
Khác plan: hàm tên `setFavoriteId` (không phải `setFavorite`); empty state viết `NO FAVORITES YET - TAP THE STAR ON A RECIPE` thay ký tự `★`.
|
||||
|
||||
**Đã verify trên emulator-5554:** rail có `FAVORITED`; ★ `VELVIPES VIVID` trong strip RECIPES → icon xám `(161,161,170)` 191 px chuyển amber `#f59e0b` 396 px, top bar **không** đổi look, `RESET` vẫn xám (không tính vào dirty); tab FAVORITED liệt kê đúng chip + ★ amber; tap chip → top bar đổi thành `VELVIPES VIVID`; bỏ ★ → chip biến mất, hiện empty state; `force-stop` rồi mở lại → vẫn còn (AsyncStorage + hydrate trong `initSession`); strip `PHOTO STYLE` **không** có nút phụ; recipe custom (tạo thử `FAVTEST`) có **2** nút phụ cạnh nhau (w=59 + 59) và 🗑 xoá sạch.
|
||||
|
||||
---
|
||||
|
||||
## 6. F6 — `RESET *` + amber khi có thay đổi
|
||||
|
||||
**Cách làm**
|
||||
1. `renderChip` đã hỗ trợ `amberValue` (chữ amber, không viền) → `{ label: resetDirty ? 'RESET *' : 'RESET', amberValue: resetDirty }`.
|
||||
2. `resetDirty` tính ở `App.tsx` (nơi có `DEFAULT_ADJUSTMENTS`, `DEFAULT_CUSTOM_WM`, `DEFAULT_GPS_WM`, `DEFAULT_CROP_RECT`) và truyền xuống prop mới `resetDirty: boolean` — tránh export thêm hằng số.
|
||||
3. Định nghĩa dirty: `selectedRecipe?.id !== 'sim-provia'` ∨ adjustments khác `DEFAULT_ADJUSTMENTS` ∨ `selectedFrame !== 'none'` ∨ `customWm`/`customWmApplied` khác default ∨ `gpsWm` khác default ∨ `useGeotag` ∨ `wmRotation !== 0` ∨ `photoRotation !== 0` ∨ `wallLandscape` ∨ crop khác default.
|
||||
4. **Đồng bộ (đã chốt):** mọi thứ tính vào dirty phải bị `handleResetAll` (`App.tsx:506`) xoá. Các mục watermark/rotate/frame đã bổ sung ở commit `7454ea1`; **còn thiếu `cropRatio` → `'none'` và `cropRect` → `DEFAULT_CROP_RECT`** → bổ sung, nếu không `RESET *` không bao giờ tắt. Đã chốt **giữ nguyên** `mode` + `aspectRatio` khi reset (không tính vào dirty).
|
||||
|
||||
**Kiểm chứng:** mặc định → `RESET` xám; đổi 1 thông số bất kỳ (kể cả chỉ kéo slider, đổi frame, đổi WB) → `RESET *` amber; tap → mọi thứ về mặc định (`PROVIPES`) và chip về `RESET` xám.
|
||||
|
||||
---
|
||||
|
||||
## 7. F7 — Chip `ROTATE`: 0/90/180/270 + xoay tay + auto chân trời
|
||||
|
||||
**Việc (đã chốt):** chip ROTATE hiện là toggle `ROTATE 90`/`ROTATE 0` (`AdjustmentPanel.tsx:600`, `App.tsx:63`) → group chip mở strip: **`RESET · AUTO · 0 · 90 · 180 · 270 · STRAIGHTEN (±45°)`** — AUTO dời lên **ngay sau RESET** (yêu cầu giữa lúc làm, khác thứ tự `… · AUTO` ghi ban đầu ở Q6).
|
||||
- `0/90/180/270` = xoay nhanh (quarter turn); `STRAIGHTEN` = slider ±45° chỉnh tay; `AUTO` = cân theo chân trời.
|
||||
- **Auto và tay kết hợp được**: `AUTO` lấy chân trời làm mốc, sau đó kéo `STRAIGHTEN` để tinh chỉnh trên mốc đó (góc hiệu dụng = auto + offset).
|
||||
- `RESET` trong strip: `quarter = 0`, `straighten = 0`, `autoHorizon = false` (RESET toàn cục ở `App.tsx:506` cũng vậy).
|
||||
|
||||
**Phần không thêm dependency**
|
||||
1. `App.tsx`: `photoRotation: 0|90` → `photoRotation: 0|90|180|270` + `photoStraighten: number` (−45..45, offset tay) + `autoHorizon: boolean`; map session cũ (`photoRotation 90` → `90`). **Góc nghiêng hiệu dụng = `photoStraighten + (autoHorizon ? roll : 0)`** — dấu là **`+roll`** (plan ban đầu ghi `−roll` là SAI, đã sửa và kiểm chứng: nghiêng máy **+10°** → ảnh xuất quay **10° theo chiều kim đồng hồ**, xem mục verify bên dưới); auto làm mốc, slider là tinh chỉnh cộng thêm, tắt AUTO thì về đúng `photoStraighten`. `horizonRoll` đo từ `Accelerometer` là số nguyên độ (lượng tử 1°).
|
||||
2. `src/utils/skiaImage.ts`: thêm `rotateSkImageBy(image, deg)` (surface đủ lớn + `canvas.rotate`); giữ `rotateSkImage90` cho quarter-turn.
|
||||
3. Áp ở `exportEngine.ts:142` + `Viewfinder.tsx:330`: quarter trước, straighten sau, kèm scale bù để không lộ góc trống.
|
||||
4. Chip `ROTATE` ghép nhãn `parts.filter(Boolean).join(' · ')` từ `['ROTATE '+photoRotation` (bỏ số khi `photoRotation === 0 && autoHorizon`, khi đó chỉ còn `ROTATE`), `autoHorizon ? 'AUTO' : null`, `photoStraighten ? ±deg : null]`. Nhãn thực tế đã verify: `ROTATE 0`, `ROTATE 180` (chỉ số), `ROTATE · AUTO`, `ROTATE 180 · AUTO`, `ROTATE · AUTO · +5.5°`; `amberValue` bật khi `photoRotation || photoStraighten || autoHorizon`.
|
||||
5. `handleResetAll` → `photoRotation=0`, `photoStraighten=0`, `autoHorizon=false`, `horizonRoll=0`; chip `RESET` trong strip cũng tắt AUTO. `resetDirty` (F6) thêm `autoHorizon` để `RESET *` vẫn tắt được.
|
||||
|
||||
**Phần auto chân trời (Q5: đồng ý thêm dep) — đã làm ở commit (2)** — `expo-sensors: ~57.0.2`, cài bằng `MSYS_NO_PATHCONV=1 node node_modules/expo/bin/cli install expo-sensors` (**`npx` hỏng từ bash → gọi thẳng `node_modules/expo/bin/cli`**); autolink qua `expo-modules-autolinking` (log build có `expo-sensors (57.0.2)`), **không cần plugin/quyền nào trong `app.json`**; **bắt buộc build lại native/APK**, JS reload không đủ.
|
||||
- Đo roll bằng `Accelerometer` (không `DeviceMotion`), mẫu 100 ms, **chỉ subscribe khi `autoHorizon && mode === 'library'`**; bỏ mẫu khi `hypot(x,y) < 0.2` (máy nằm phẳng) hoặc `|góc| > 45` (dựng đứng) để level không đuổi theo nhiễu; `horizonRoll = Math.round(atan2(x, y) * 180/π)` (lượng tử 1°).
|
||||
- Level line trên preview: `autoRoll = autoHorizon && mode === 'library' ? horizonRoll : null`; vạch rộng 55% (4 px nền `rgba(0,0,0,0.5)` + 2 px nét), `transform: [{ rotate: autoRoll + 'deg' }]`, **amber `#f59e0b` khi `|autoRoll| <= 1`**, trắng khi lệch; vẽ ở **cả 3 nhánh library** (polaroid, wall, mặc định).
|
||||
- Góc bake vào export đi **cùng một đường** với straighten tay: `effectiveStraighten = photoStraighten + (autoHorizon && mode === 'library' ? horizonRoll : 0)` → `exportOptions.photoStraighten` + prop `photoStraighten` của `Viewfinder`.
|
||||
- Hệ quả (đã thực hiện): F7 **tách 2 commit** — (1) quarter + straighten tay (không dep), (2) auto chân trời (kèm dep + build native).
|
||||
|
||||
**Kiểm chứng:** xuất ảnh ở 90/180/270 → kích thước pixel + hướng đúng; straighten ±5° nghiêng đúng chiều; không lộ góc trong; frame/crop khớp preview; auto chân trời: nghiêng máy ~10° → ảnh xuất ra thẳng; bật AUTO rồi kéo STRAIGHTEN ±2° → tổng góc = auto + offset; chọn `180` khi auto bật → ảnh vẫn thẳng (quarter + auto độc lập).
|
||||
|
||||
**Đã làm (commit 1 `9ddaeac feat(frame): give ROTATE quarter turns plus a fine straighten`) — 5 file:**
|
||||
- `src/utils/skiaImage.ts`: thêm `rotateSkImageBy(image, deg)` (surface đủ lớn + `canvas.rotate`, crop giữa giữ tỉ lệ gốc → không lộ góc trống); giữ `rotateSkImage90` cho quarter-turn.
|
||||
- `App.tsx`: `photoRotation` mở rộng `0|90|180|270` + thêm `photoStraighten`; `handleResetAll` xoá cả hai; `resetDirty` tính thêm chúng.
|
||||
- `src/utils/exportEngine.ts` + `Viewfinder.tsx`: quarter trước, straighten sau.
|
||||
- `AdjustmentPanel.tsx`: `photoRotation` thành `groupDefs.rotate` (strip `RESET · 0 · 90 · 180 · 270 · STRAIGHTEN (±45°)`, lúc này chưa có AUTO) + `openParam === 'straighten'` mở slider ±45°.
|
||||
|
||||
**Đã làm (commit 2 `0ce561c feat(frame): level the photo to the horizon with AUTO`) — 5 file:** `App.tsx` (subscribe `Accelerometer` + `horizonRoll` + `effectiveStraighten`), `AdjustmentPanel.tsx` (chip `AUTO` ngay sau `RESET` + nhãn ghép + `amberValue`), `Viewfinder.tsx` (level line), `package.json`/`package-lock.json` (`expo-sensors`). Chi tiết ở các gạch đầu dòng phía trên.
|
||||
|
||||
**Verify commit 2 trên `emulator-5554`** (điều khiển cảm biến bằng `adb -s emulator-5554 emu sensor set acceleration x:y:z`, mặc định `0:9.77631:0.812349`):
|
||||
- Strip đúng thứ tự `ROTATE · RESET · AUTO · 0 · 90 · 180 · 270 · STRAIGHTEN (±45°)`; nhãn chip lần lượt `ROTATE 0` → `ROTATE · AUTO` → `ROTATE · AUTO · +5.5°` → `ROTATE 180 · AUTO`; `RESET *` amber ở mọi trạng thái đã đổi.
|
||||
- Dấu `+roll` chứng minh bằng ảnh thật: nghiêng `(1.703, 9.661, 0.81)` (= +10°) → khớp nhất `PIL rotate(-10)` (MAD **1.18**, so với **47.9** khi không xoay); nghiêng ngược `(-1.703, …)` → `PIL rotate(+10)` (MAD 1.16) ⇒ đối xứng hai chiều.
|
||||
- AUTO + STRAIGHTEN +5.5 → tổng **+15.5** (MAD 1.24); quarter `180` + AUTO → `base.rotate(180)` rồi model(−10°) (MAD 1.13) ⇒ quarter và auto độc lập, cộng được.
|
||||
- Level line: roll 0 → ~594 px amber (55% của 1080) ở hàng y≈1199–1203; roll +10 → vạch dốc **9,93°** trên màn hình (đúng bằng góc nghiêng máy).
|
||||
- `RESET` toàn cục và `RESET` trong strip đều tắt AUTO (chip về `ROTATE 0`, `RESET` hết amber). Cảm biến emulator đã trả về giá trị mặc định sau khi test.
|
||||
- Lưu ý khi đo: **cài lại APK sẽ reset look/session của app** → luôn xuất lại ảnh nền (base) mới sau mỗi lần install trước khi so ảnh.
|
||||
|
||||
---
|
||||
|
||||
## 8. Thứ tự thực hiện & commit
|
||||
|
||||
| Phase | Hạng mục | Rủi ro | Ghi chú |
|
||||
|---|---|---|---|
|
||||
| A | F2 đổi tên → F1 PHOTO STYLE → F4 TEMP → F6 RESET* | thấp | thuần UI, 1 commit/hạng mục |
|
||||
| B | F5 FAVORITED | thấp–TB | thêm storage key, không đổi shape dữ liệu cũ |
|
||||
| C | F3 watermark → FRAME **+ pinch/drag cửa sổ frame ở camera** | TB–cao | đụng `TabId`, `wmEditing`, `feedRectSync`/worklet, cử chỉ 2 ngón; tách 2 commit: UI trước, pinch/drag sau |
|
||||
| D | F7 rotate | cao | đổi kiểu state + đường export; **auto: `expo-sensors` + build lại native** |
|
||||
|
||||
Mỗi hạng mục: sửa → typecheck (14) → build → install → verify trên máy → commit riêng (`feat(...)`: mô tả hành vi). F7 và F3 mỗi hạng mục tách 2 commit (F3: UI trước → pinch/drag camera sau; F7: manual trước → auto sau).
|
||||
|
||||
---
|
||||
|
||||
## 9. Quy trình kiểm chứng (dùng lại)
|
||||
|
||||
```bash
|
||||
export MSYS_NO_PATHCONV=1
|
||||
cd /c/Users/locpham/RecipesCam
|
||||
node --stack-size=16000 ./node_modules/typescript/lib/tsc.js --noEmit 2>&1 | grep -c "error TS" # = 14
|
||||
cd android && cmd /c gradlew.bat assembleRelease # ~1 phút
|
||||
adb -s emulator-5554 install -r app/build/outputs/apk/release/app-release.apk
|
||||
```
|
||||
|
||||
Trên máy: `uiautomator dump` lấy toạ độ (helper scratchpad `ui.py`), `screencap` + lấy màu nền chip ở `(x1−4, cy)` để phát hiện chip active (amber ≈ `(63,50,32)`/`(48,35,15)`, neutral ≈ `(31,31,35)`/`(27,27,30)`); tap chỉ ăn khi IME đã đóng (tap lần 2 sau khi gõ); toạ độ tool-rail đổi theo tab → luôn dump lại; `which`/`locale`/`sleep` không có → dùng `powershell.exe -Command "Start-Sleep -Seconds N"`.
|
||||
|
||||
---
|
||||
|
||||
## 10. Câu hỏi cần chốt trước khi làm
|
||||
|
||||
1. ~~`ASTIPCES` hay `ASTIPES`?~~ → **`ASTIPES`**; ~~`CLASSIC CHRIPES` hay `CLASSIC CHROMES`?~~ → **giữ `CLASSIC CHRIPES`**.
|
||||
2. ~~Recipe bundled có đổi tên theo không?~~ → **Có**: `PROVIPES STD`, `VELVIPES VIVID`, `ACRIPES MONO`, `CLASSIC NEGIPES.` (giữ nguyên id).
|
||||
3. ~~Cho tab FRAME hiện ở cả camera?~~ → **Có**, và **kèm pinch/drag cửa sổ frame ở camera** (F3 bước 7).
|
||||
4. ~~Chip `PHOTO STYLE` có hiện tên sim không?~~ → **Không** (TopBar góc trái đã hiện tên).
|
||||
5. ~~Cho phép thêm `expo-sensors`?~~ → **Có**, chấp nhận build lại native.
|
||||
6. ~~Xoay tay gồm những gì?~~ → **`RESET · AUTO · 0 · 90 · 180 · 270 · STRAIGHTEN ±45°`** (AUTO dời lên ngay sau RESET khi làm), auto + tay kết hợp được.
|
||||
7. ~~FAVORITED gồm cả sim không?~~ → **Chỉ recipe** (sim đã nằm trong recipe qua `baseFilter`).
|
||||
8. ~~RESET có reset crop không?~~ → **Có, reset `cropRatio`/`cropRect`**; mode + aspect **vẫn giữ** như hiện tại.
|
||||
|
||||
---
|
||||
|
||||
## 11. Đối chiếu 7 hạng mục yêu cầu → commit
|
||||
|
||||
| # | Yêu cầu | Commit |
|
||||
|---|---|---|
|
||||
| 1 | Gom sim vào chip `PHOTO STYLE` | `d66ae3d` |
|
||||
| 2 | Đổi tên sim (kèm recipe bundled) | `6adf0b5` |
|
||||
| 3 | `WATERMARK` → chip trong `FRAME` (+ pinch/drag cửa sổ frame ở camera) | `4131c9f` + `dcb74a7` |
|
||||
| 4 | WB presets vào chip `TEMP` | `7ed4301` |
|
||||
| 5 | Tab `FAVORITED` | `ba971f1` |
|
||||
| 6 | `RESET *` amber khi có thay đổi | `ba7e579` |
|
||||
| 7 | Chip `ROTATE` (0/90/180/270 · STRAIGHTEN ±45° · AUTO) | `9ddaeac` + `0ce561c` |
|
||||
|
||||
Phát sinh trong lúc làm: đổi tên chip frame `RETRO POLAROID` → `RETRO INSTANT` (`6788d51`).
|
||||
|
||||
---
|
||||
|
||||
## 12. Follow-up — 4 lỗi hành vi của strip `ROTATE` (commit `da48cec`)
|
||||
|
||||
Bốn lỗi phát hiện sau khi F7 xong, sửa chung một commit `fix(frame): make the ROTATE strip behave on switching parameters`: `App.tsx` + `src/components/AdjustmentPanel.tsx`.
|
||||
|
||||
| # | Lỗi | Nguyên nhân | Cách sửa |
|
||||
|---|---|---|---|
|
||||
| 1 | Sau khi dùng AUTO straighten, chuyển sang tham số khác thì hàng slider STRAIGHTEN vẫn còn | chip frame (`choiceChips('fr', …)`) và chip `RESET` chỉ gọi handler của chúng, không đóng `openParam` | chip frame gọi `if (openParam) onOpenParamChange(null)` trước `onUpdateFrame`; chip `RESET` thêm `setOpenGroup(null)` + đóng `openParam` trước `onResetAll` |
|
||||
| 2 | Mở ảnh khác từ thư viện phải reset straighten + AUTO về mặc định | `handlePickImage` chỉ `setPhotoRotation(0)` | thêm `setPhotoStraighten(0)` + `setAutoHorizon(false)` + `setHorizonRoll(0)` |
|
||||
| 3 | Trong straighten thủ công, nút `<` không hiện lại strip | `toggleParam` đóng `openGroup`, `<` gọi `toggleParam` nên strip biến mất | thêm map `PARAM_GROUP = { straighten: 'rotate', temperature: 'temp' }`; `<` gọi `toggleParam` rồi `setOpenGroup(PARAM_GROUP[key])` (set sau thắng) |
|
||||
| 4 | Đang straighten thủ công, nhấn AUTO không có tác dụng | `effectiveStraighten = photoStraighten + roll`; roll≈0 nên bật AUTO không đổi gì | nhánh `auto` trong `groupDefs.rotate.onPick`: khi **bật** (`!autoHorizon`) gọi `onStraighten(0)` để ép về đường chân trời sensor; nhấn lại vẫn tắt AUTO như cũ |
|
||||
|
||||
Kiểm chứng trên `emulator-5554` (typecheck vẫn **14**, `assembleRelease` OK, cài lại APK rồi mở một ảnh thư viện):
|
||||
|
||||
- Đặt STRAIGHTEN thủ công **+22°** → chip `ROTATE 0 · +22°`; nhấn **AUTO** → chip còn `ROTATE · AUTO`, mở lại slider thấy **0°** (item 4).
|
||||
- Mở slider rồi nhấn chip frame **CLASSIC BORDER** → hàng slider STRAIGHTEN biến mất (item 1); nhấn `RESET` cũng đóng hàng slider và đưa về `ROTATE 0` (item 1, phần RESET).
|
||||
- Nhấn `<` trong hàng slider → strip `RESET · AUTO · 0 · 90 · 180 · 270 · STRAIGHTEN (±45°)` hiện lại (item 3).
|
||||
- Mở ảnh thứ hai từ thư viện khi AUTO đang bật → chip về `ROTATE 0` (straighten 0, AUTO tắt) (item 2).
|
||||
|
||||
Ghi chú: AUTO vẫn tắt được bằng cách nhấn lại chính chip AUTO; strip `RESET` xoá cả turn + góc + auto.
|
||||
|
||||
---
|
||||
|
||||
## 13. Follow-up — vạch amber của bubble level còn đọng trên ảnh (commit `d6b3ebd`)
|
||||
|
||||
Yêu cầu: sau khi nhấn `ROTATE` → `AUTO`, chuyển sang chip khác thì vạch amber vẫn nằm trên ảnh.
|
||||
|
||||
Đó **không phải** hàng slider STRAIGHTEN mà là vạch bubble level vẽ đè lên ảnh trong `Viewfinder` (`levelOverlay`, hiện khi `autoRoll != null`, amber khi `|roll| <= 1`). Nguyên nhân: `autoRoll = autoHorizon && mode === 'library' ? horizonRoll : null` chỉ phụ thuộc AUTO, không biết strip `ROTATE` đã đóng hay chưa.
|
||||
|
||||
| # | Lỗi | Cách sửa |
|
||||
|---|---|---|
|
||||
| 1 | Vạch amber đọng lại sau khi rời ngữ cảnh `ROTATE` | `App.tsx`: thêm `rotatePanelOpen`; `autoRoll` chỉ khác `null` khi `autoHorizon && mode === 'library' && openTab === 'frame' && rotatePanelOpen` |
|
||||
| 2 | App không biết strip `ROTATE` đang mở | `AdjustmentPanel.tsx`: thêm prop `onRotatePanelChange`, effect `onRotatePanelChange(openGroup === 'rotate' \|\| openParam === 'straighten')` deps `[openGroup, openParam]` |
|
||||
| 3 | Chip frame khác không đóng strip `ROTATE` | nhánh `choiceChips('fr', …)` thêm `setOpenGroup(null)` cạnh `onOpenParamChange(null)` sẵn có |
|
||||
|
||||
Kiểm chứng trên `emulator-5554` (typecheck vẫn **14**, `assembleRelease` OK, cài lại APK, mở một ảnh thư viện, script `verify3.py`):
|
||||
|
||||
- Đối chứng dương: strip mở + `AUTO` bật (`ROTATE · AUTO`) → dò được vạch amber ở hàng **y 1199–1203**, rộng **594 px**.
|
||||
- Nhấn chip frame `NO FRAME` khi AUTO đang bật → strip đóng, hàng amber **`[]`**.
|
||||
- Nhấn tab rail `LIGHT` → hàng amber **`[]`**; quay lại `FRAME` → vẫn **`[]`** (strip đóng nên vạch không tự hiện lại).
|
||||
|
||||
Ngoài lề: `AUTO` chỉ là bubble level theo cảm biến gia tốc (`Accelerometer`), không dò đường chân trời trong ảnh, nên không nắn được đường chân trời nghiêng sẵn trong ảnh.
|
||||
|
||||
---
|
||||
|
||||
## 14. Bỏ hẳn AUTO theo cảm biến, thay bằng auto-straighten dò đường chân trời trong ảnh
|
||||
|
||||
Yêu cầu: EXIF ảnh trong app không lưu roll lúc chụp, nên AUTO theo gia tốc chỉ đúng khi đang chụp chứ vô nghĩa với ảnh mở từ thư viện. Bỏ hẳn cảm biến, tự dò độ nghiêng từ chính ảnh.
|
||||
|
||||
| # | Việc | Nơi |
|
||||
|---|---|---|
|
||||
| 1 | Thêm bộ dò nghiêng | file mới `src/utils/horizon.ts`, `detectTilt(image: SkImage \| null, maxAngle = 45): number \| null` |
|
||||
| 2 | Bỏ cảm biến, AUTO lấy từ ảnh | `App.tsx`: xoá import `expo-sensors`, state `autoHorizon`/`horizonRoll` + effect đọc `Accelerometer`, `effectiveStraighten`; `handleAutoStraighten` = `const tilt = viewfinderRef.current?.detectHorizon?.() ?? null; if (tilt == null) return; setPhotoStraighten(-tilt);` |
|
||||
| 3 | Bỏ vạch bubble level | `Viewfinder.tsx`: xoá prop `autoRoll` + overlay; thêm `detectHorizon()` vào `ViewfinderHandle` (chạy `detectTilt(applyPhotoRotation(src.image, src.quarter, 0))` trên `horizonSrcRef`) |
|
||||
| 4 | Chip | `AdjustmentPanel.tsx`: `autoHorizon`/`onAutoHorizon` → `onAutoStraighten`; nhánh AUTO gọi `onAutoStraighten()`; hàng slider `STRAIGHTEN` hiện `min -45 max 45 step 0.5` và đọc số đo |
|
||||
|
||||
Chi tiết đã bỏ: prop `onRotatePanelChange` (mục 13 không còn cần vì không còn vạch đè lên ảnh), cặp `setAutoHorizon(false); setHorizonRoll(0);` trong `handleResetAll`.
|
||||
|
||||
**Thuật toán `detectTilt` (shear projection).** Thuật toán ban đầu (histogram hướng gradient) sai với cạnh thật: đường 7° đi từng bậc 8 px nên gradient chỉ rơi vào bin 0 (đoạn phẳng) và 45 (bước nhảy) — mọi góc nhỏ đều đọc ra 0 (kiểm chứng số: app bin 0/ratio 0.207, mô phỏng 2-tap bilinear cho đúng (0, 0.206, 133044)). Cách làm đúng:
|
||||
|
||||
1. Hạ mẫu chính xác 2× liên tục đến khi cạnh dài ≤ 256 (`Skia.Surface.Make` + `canvas.drawImageRectOptions(..., FilterMode.Linear, MipmapMode.Linear)` + `makeImageSnapshot`) — hạ một lần >2× sẽ alias.
|
||||
2. `readPixels()` → mảng xám; liệt kê điểm cạnh `mag = sqrt(dx²+dy²)`, bỏ qua `mag < 20`, toạ độ x trừ tâm, bỏ nếu < 32 điểm.
|
||||
3. `score(deg)` = cắt xiên theo phương pháp profile: `acc[round(py - px*tan(deg)) + limit] += pw`, trả `ss/(tot*tot)` (độ "nhọn" của profile).
|
||||
4. Quét thô `-45..45` bước 1°, cổng tin cậy `bestScore < 3 * median → null`, rồi tinh chỉnh bước 0.5° trong `best-1..best+1`; trả về độ lẻ.
|
||||
|
||||
Dấu: `rotateSkImageBy` dùng `canvas.rotate(deg)` (dương = cùng chiều kim đồng hồ, y hướng xuống) nên `photoStraighten = -tilt`.
|
||||
|
||||
Kiểm chứng trên `emulator-5554` (typecheck vẫn **14**, `assembleRelease` OK, cài lại APK, script `autocheck.py` / `expexport.py`):
|
||||
|
||||
- Ảnh nghiêng 7° → STRAIGHTEN **-7°**; ảnh 0° → **0°**; ảnh 12° có cấu trúc dọc → **-12°**; ảnh nhiễu đồng nhất (không có đường) → **0°** (cổng tin cậy chặn đúng).
|
||||
- Export thật (không chỉ đọc slider): AUTO trên ảnh nghiêng 12°, SAVE, đo lại đường chân trời trong file xuất → **-0.85°**. Đo cùng thước đo trên ảnh gốc cho **-0.0° / 7.0° / 12.0°** (trên 1200 cột) ⇒ thước đo đủ nhạy, -0.85° là ảnh đã được nắn thật.
|
||||
|
||||
Ghi chú: `expo-sensors` vẫn còn trong `package.json` (không dùng nữa) — cố ý không sửa deps.
|
||||
|
||||
## 15. Điều tra "load camera bị chậm"
|
||||
|
||||
Kết luận: **không có nút thắt nào trong code app**. Số đo trên `emulator-5554`, APK release tại `1b632ba`, **không** instrument (đo bằng logcat native + `screencap`):
|
||||
|
||||
Cold start (`am force-stop` → `am start`, mốc 0 = dòng `START u0`):
|
||||
|
||||
| Mốc | Thời điểm |
|
||||
|---|---|
|
||||
| `Displayed com.locphamtran.recipescamera` | 0.5 – 1.0 s |
|
||||
| `HybridCameraSessionSpec: Reconfiguring CameraSession` | 1.2 – 1.7 s |
|
||||
| `Camera #0 State changed! Type: OPEN` | 1.7 – 2.6 s |
|
||||
| Preview sống hiện trên màn hình | **~2.5 s** |
|
||||
|
||||
Preview đo bằng cách chụp màn hình tại các mốc cố định rồi tính độ sáng trung bình (54×120, thang xám): 1.5 s → mean 2.9; 2.0 s → 3.5 (đen); 2.5 s → 39.7 (có nội dung). Kết quả lặp lại y hệt sau `adb reboot`.
|
||||
|
||||
Resume từ background (HOME → mở lại): `START u0` → `Camera #0 OPEN` = **282 ms**.
|
||||
|
||||
**Vì sao các lần đo trước ra 5–7 s:** những lần đó chạy ngay sau `gradlew assembleRelease` (host bận + dexopt nền của APK mới). Chính APK đó, đo lại 12+ lần cold start (kể cả sau `adb reboot` và `install -r`) đều 1.7–2.6 s. Log của các lần chậm có `Davey! duration=2803ms` / `Choreographer Skipped 188 frames` ⇒ main thread bị đói; `session.configure()` của VisionCamera chạy trên `Dispatchers.Main` nên bị xếp hàng sau frame dài, kéo `Camera #0 OPEN` ra ~7 s. Bản thân đường camera rất nhanh: `createCameraSession` ~10 ms, thân `configure` ~25 ms.
|
||||
|
||||
⇒ Không sửa code. Instrumentation tạm đã gỡ sạch (`git checkout -- App.tsx index.ts src/components/Viewfinder.tsx`, khôi phục `useCameraSession.ts` / `useCameraController.ts` từ `.bak` trong `node_modules`, xoá `.bak`).
|
||||
|
||||
Ghi chú không sửa: APK release **172 MB**, trong đó 4 ABI (`x86` 41 MB, `x86_64` 41 MB, `arm64-v8a` 40 MB, `armeabi-v7a` 28 MB). Chỉ ảnh hưởng thời gian cài/first-run trên emulator, không phải thời gian load camera.
|
||||
|
||||
Script đo: `cold.py` (timeline logcat) và `onset.py` (độ sáng màn hình) trong scratchpad.
|
||||
|
||||
## 16. Sửa: preview khác ảnh export ở chế độ đo sáng Highlight-weighted
|
||||
|
||||
**Triệu chứng (người dùng):** ở chế độ CAMERA với METERING MODE = HIGHLIGHT-WEIGHTED, lúc bấm máy ngắm sáng bừng lên toàn khung, rồi ảnh xuất ra không khớp với những gì đã thấy trên ngắm.
|
||||
|
||||
**Nguyên nhân:** chế độ highlight cộng thêm **-0,5 EV** (`METER_HL_EV`). EV này được đẩy xuống **hardware AE bias** ngay trước khi chụp (`flushExposureBias()` trong `handleCapture`), còn export thì bỏ qua EV phần mềm (`evFromCamera: true`) vì tin rằng bias phần cứng đã gánh EV.
|
||||
|
||||
- `Viewfinder.flushExposureBias()` gọi `setHwBiasStops(clamped / 8)` **trước khi** `setExposureBias()` thực sự landed. Preview render `evTarget − hwBiasStops`, nên ngay khi bấm máy gain phần mềm tụt từ -0,5 EV về 0 → cả khung sáng lên đúng 0,5 EV (= cái "flash" người dùng thấy).
|
||||
- Bias phần cứng landed bất đồng bộ (một số máy/emulator không hề tôn trọng nó), nên JPEG có thể được chụp với EV = 0 → ảnh sáng hơn ngắm ~0,5 EV (+41% tuyến tính).
|
||||
|
||||
**Cách sửa — EV thuần phần mềm, bỏ hẳn hardware bias:**
|
||||
|
||||
| | Trước | Sau |
|
||||
| --- | --- | --- |
|
||||
| Preview EV | `clamp(user EV + metering offset) − hwBiasStops` | `clamp(adjustments.exposureCompensation)` |
|
||||
| Export EV | bỏ qua (`evFromCamera: true`), trông vào AE bias | gain `2^EV` như preview (không còn `evFromCamera`) |
|
||||
| Hardware AE bias | ghi lúc bấm máy | **không bao giờ ghi** |
|
||||
| EV dip của highlight | prop `meteringEvOffset` riêng + `METER_HL_EV` | nằm luôn trong `meteringAdj.exposureCompensation` (dùng chung cho preview và export) |
|
||||
|
||||
Xoá hẳn: `ViewfinderHandle.flushExposureBias()`, state `hwBiasStops`, `evStopsRef`, `meteringEvOffsetRef`, effect cảnh báo `supportsExposureBias`, prop `meteringEvOffset`, và lời gọi trong `handleCapture`. `evFromCamera` vẫn còn trong `exportEngine`/`nativeExport` nhưng không đường nào của app truyền nữa. Net **−71 dòng** (`App.tsx`, `src/components/Viewfinder.tsx`).
|
||||
|
||||
**Đo trên `emulator-5554`, APK release, màn 1080×2400.** Vùng ảnh của preview xác định được bằng profile độ sáng: ảnh hiển thị vừa khung (*contain*) 1080×1440 tại `y 480..1920`, letterbox đen hai đầu (đúng 1280 × 1080/960 = 1440), nên so sánh được cùng trường nhìn: preview `[480:1920, 0:1080]` ↔ export 960×1280.
|
||||
|
||||
Độ sáng trung bình vùng ảnh của preview, highlight-weighted, một lần bấm máy:
|
||||
|
||||
| Mốc | pre | ngay sau bấm | +0,35 s | +1,35 s | lúc đã settle |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| Lần 1 | 46,40 | 46,74 | 46,68 | 46,59 | 46,80 |
|
||||
| Lần 2 | 60,65 | 60,41 | 60,37 | 60,23 | 60,62 |
|
||||
|
||||
Không còn nhảy sáng (dao động ±0,7%, dưới mức nhiễu của phép đo).
|
||||
|
||||
Tỉ lệ export/preview (E/P) cùng trường nhìn:
|
||||
|
||||
| Chế độ | preview | export | E/P |
|
||||
| --- | --- | --- | --- |
|
||||
| EVALUATIVE | 100,02 | 101,62 | **1,0160** |
|
||||
| HIGHLIGHT-WEIGHTED (lần 1) | 60,38 | 62,34 | **1,0325** |
|
||||
| HIGHLIGHT-WEIGHTED (lần 2) | 60,65 | 62,23 | **1,0260** |
|
||||
|
||||
Hai chế độ lệch nhau 1,6% ≈ 0,023 EV (độ lệch hệ thống nhỏ của JPEG/resample). Nếu export vẫn bỏ EV như code cũ, E/P của highlight phải lớn hơn eval ≈ 1,414 lần (+41%). Sai số từng pixel `mean|diff|` ≈ 8,7–9,3 (thang 0–255).
|
||||
|
||||
**Kiểm tra:** `tsc --noEmit` vẫn **14 lỗi** như baseline (không thêm lỗi nào). File test trên máy đã xoá (3 JPEG export + 3 dòng MediaStore 1000000266–268 + `/sdcard/ui.xml`); DCIM còn đúng 5 ảnh `IMG_*` gốc.
|
||||
|
||||
Script đo: `measure.py` (chụp màn hình trước/sau khi bấm máy + pull JPEG export), `align.py` (dò căn chỉnh preview↔export) trong scratchpad.
|
||||
|
||||
## 17. Sửa 3 lỗi hành vi chip/strip: EV bubble · cuộn chip đã sửa · strip làm mất chip
|
||||
|
||||
Ba lỗi người dùng báo, đã sửa, build release, cài lên `emulator-5554` và đo lại.
|
||||
|
||||
### 17.1 EV bubble hiện EV đã gộp thay vì EV người dùng nhập
|
||||
|
||||
**Triệu chứng:** tab LIGHT để EV = 0 nhưng bubble quick-EV trên màn hình hiện **EV −0,5**.
|
||||
|
||||
**Nguyên nhân:** `src/components/Viewfinder.tsx` (~dòng 504) đọc `adjustments.exposureCompensation`. Biến `adjustments` (~dòng 362) là `{...recipe.adjustments, ...meteringAdjustments}`, nên ở chế độ HIGHLIGHT-WEIGHTED nó đã cộng sẵn offset −0,5 EV của metering. Bubble/thumb/điểm neo kéo đều lấy từ đây → hiện `userEV − 0,5`.
|
||||
|
||||
**Sửa:** cho phần hiển thị đọc thẳng `recipe.adjustments.exposureCompensation`; gain áp dụng vẫn dùng `adjustments.exposureCompensation` như cũ.
|
||||
|
||||
```tsx
|
||||
const evStops = recipe.adjustments.exposureCompensation ?? 0;
|
||||
```
|
||||
|
||||
| | Trước | Sau |
|
||||
| --- | --- | --- |
|
||||
| Bubble / thumb / điểm neo kéo | `adjustments.exposureCompensation` (đã gộp metering) | `recipe.adjustments.exposureCompensation` (EV người dùng) |
|
||||
| Gain áp dụng vào ảnh | `clampEvApplied(adjustments…)` | không đổi |
|
||||
|
||||
**Đo trên máy** (HIGHLIGHT-WEIGHTED bật, đã chứng minh có áp dụng: độ sáng trung bình preview 92,74 → 58,47):
|
||||
|
||||
| Thao tác | Bubble | Chip LIGHT |
|
||||
| --- | --- | --- |
|
||||
| Không kéo | `EV 0.0` tại `[687,1049][797,1095]` | `EV 0.0 EV` |
|
||||
| Kéo +130 px | `EV +0.8` | `EV +0.8 EV` |
|
||||
| Kéo về | `EV 0.0` | `EV 0.0 EV` |
|
||||
|
||||
Code cũ khi kéo +130 px chỉ ghi `+0.3` (bị trừ 0,5). Kéo về 0 khôi phục đúng.
|
||||
|
||||
### 17.2 Nhấn tab trên rail phải nhảy tới chip đầu tiên đã sửa
|
||||
|
||||
**Yêu cầu:** nhấn tab → cuộn tới chip đầu tiên có thông số đã sửa; nếu tab không có chip nào sửa thì về chip đầu tiên.
|
||||
|
||||
**Sửa trong `src/components/AdjustmentPanel.tsx`:** thêm hằng số cấp module để nhận diện cả 5 strip con của watermark là "cùng một panel":
|
||||
|
||||
```tsx
|
||||
const WM_SUBGROUPS: GroupKey[] = ['wmColor','wmSize','wmFont','gpsColor','gpsName','gpsTime'];
|
||||
const isWmPanel = (g) => g === 'wm' || (g !== null && WM_SUBGROUPS.includes(g));
|
||||
```
|
||||
|
||||
Toàn bộ khối `switch (activeTab)` dựng hàng chip (~dòng 719–880) được viết lại quanh một helper duy nhất:
|
||||
|
||||
```tsx
|
||||
const chipsScrollRef = useRef<ScrollView>(null);
|
||||
const chipXRef = useRef<Record<string, number>>({});
|
||||
const firstAmberChipRef = useRef<string | null>(null);
|
||||
useEffect(() => {
|
||||
const id = setTimeout(() => {
|
||||
const key = firstAmberChipRef.current;
|
||||
const x = key ? chipXRef.current[key] ?? 0 : 0;
|
||||
chipsScrollRef.current?.scrollTo({ x: Math.max(0, x - 8), animated: false });
|
||||
}, 80);
|
||||
return () => clearTimeout(id);
|
||||
}, [activeTab, isWmPanel(openGroup)]);
|
||||
|
||||
const chipRow = (chips: ChipDef[], padV = 6) => {
|
||||
firstAmberChipRef.current = chips.find((c) => c.amberValue)?.key ?? null;
|
||||
return (<ScrollView ref={chipsScrollRef} horizontal showsHorizontalScrollIndicator={false}
|
||||
contentContainerStyle={{ alignItems: 'center', paddingHorizontal: 8, paddingVertical: padV }}>
|
||||
{chips.map(renderChip)}</ScrollView>);
|
||||
};
|
||||
```
|
||||
|
||||
- Mỗi `case` trả về `chipRow([...định nghĩa], 8|6)` (recipes/favorites/frame giữ padding 8, iq/wb/filters 6 — đúng như cũ). Bỏ `<View>` bọc vô nghĩa quanh hàng WB.
|
||||
- `renderChip` thêm `onLayout` trên View ngoài: `chipXRef.current[c.key] = e.nativeEvent.layout.x`.
|
||||
- `paramChips` và `choiceChips` đổi kiểu trả về thành `ChipDef[]` (trước trả ReactElement); call site duy nhất còn lại thành `{choiceChips(openGroup, …).map(renderChip)}`.
|
||||
- Độ trễ 80 ms vì hàng chip vừa remount khi đổi tab, `scrollTo` ngay sẽ trượt.
|
||||
|
||||
**Đo trên máy:**
|
||||
|
||||
| Kịch bản | Kết quả |
|
||||
| --- | --- |
|
||||
| LIGHT mới, chưa sửa gì | offset 0, `RESET` đầu hàng |
|
||||
| Đặt COLOR +7, sang WB rồi quay lại LIGHT | `COLOR +7` nằm tại `[246,1921][384,1960]` |
|
||||
| WB (không có chip nào sửa) | offset 0 |
|
||||
| FRAME khi watermark đang ON | cuộn tới `WATERMARK ON` tại `[783,1916][1017,1955]` |
|
||||
|
||||
### 17.3 Strip của watermark làm cả hàng chip biến mất
|
||||
|
||||
**Triệu chứng:** đang ở chip watermark, bấm sang chip kế tiếp thì chỉ thấy strip, không thấy chip nào; phải bấm vào strip thì chip mới hiện.
|
||||
|
||||
**Nguyên nhân:** hàng chip con của watermark trong `case 'frame'` bị gác bằng `if (openGroup === 'wm')`, nên mọi strip con (`wmColor`, `wmSize`, `wmFont`, `gpsColor`, `gpsName`, `gpsTime`) đều làm hàng chip gỡ khỏi cây.
|
||||
|
||||
**Sửa:**
|
||||
|
||||
- Cổng hiển thị `if (openGroup === 'wm')` → `if (isWmPanel(openGroup))` — hàng chip ở lại với mọi strip của watermark.
|
||||
- `onWmPanelChange(openGroup === 'wm')` → `onWmPanelChange(isWmPanel(openGroup))` — giữ lớp bắt kéo ảnh.
|
||||
- ScrollView của strip thêm `key={openGroup}` để remount về offset 0 mỗi nhóm.
|
||||
- Strip `ROTATE`: chuyển `{ v: 'straighten', d: 'STRAIGHTEN (±45°)' }` lên **đầu** (trước bị cắt ở x≈1058).
|
||||
- Strip `TEMP`: đổi option thành `[{ v: 'color-temp', d: 'COLOR TEMP' }, ...WB_PRESETS]`.
|
||||
|
||||
**Đo trên bản build cuối:**
|
||||
|
||||
| Kịch bản | Chip còn hiển thị? | Strip |
|
||||
| --- | --- | --- |
|
||||
| FRAME › WATERMARK › CUSTOM WATERMARK ON › TEXT COLOR | Có: `RESET / CUSTOM WATERMARK ON / ROTATE / ACCEPT / CANCEL / TEXT COLOR` | AMBER WHITE BLACK RED BLUE |
|
||||
| TEXT SIZE | Có, nguyên vẹn | cỡ chữ |
|
||||
| GPS ON › TIME | Có, nguyên vẹn | định dạng giờ |
|
||||
| ROTATE | Có | `STRAIGHTEN (±45°)` tại `[258,1805][534,1844]`, một tap mở `STRAIGHTEN 0°` |
|
||||
| TEMP | Có | `COLOR TEMP` đầu tiên tại `[233,1815][418,1854]` |
|
||||
|
||||
Vào panel con từ hàng frame đã cuộn giờ nhắm lại offset 0 (`< FRAME` đầu hàng) thay vì thừa hưởng offset của hàng frame.
|
||||
|
||||
### 17.4 Kiểm tra
|
||||
|
||||
- `tsc --noEmit` (`node --stack-size=16000 ./node_modules/typescript/lib/tsc.js`) vẫn **14 lỗi** như baseline (1 `App.tsx` + 13 `src/components/Viewfinder.tsx`); `AdjustmentPanel.tsx` 0 lỗi.
|
||||
- Build `gradlew.bat --offline assembleRelease` → BUILD SUCCESSFUL; `adb install -r` → Success; khởi động lại app OK.
|
||||
- Trạng thái máy đã reset: RESET chip đã bấm (GPS watermark OFF, custom watermark OFF, frame NO FRAME, COLOR 0, straighten 0). LIGHT: `RESET / EXPOSURE 0 / EV 0.0 EV / CONTRAST 0 / COLOR 0`; WB: `RESET / TEMP AUTO / TINT 0 / COLOR CHROME / CHROME BLUE`. Metering về EVALUATIVE (mặc định lúc khởi động).
|
||||
- File test trên máy đã xoá (`/sdcard/*.xml` của phiên này).
|
||||
- Diff chưa commit: `App.tsx`, `src/components/AdjustmentPanel.tsx`, `src/components/Viewfinder.tsx` + file kế hoạch này.
|
||||
|
||||
## 18. CROP theo tỉ lệ: nút APPLY, ẩn viền amber, RESET về mặc định
|
||||
|
||||
**Yêu cầu:** sau khi CROP với ratio thì cho phép APPLY; sau khi APPLY chỉ hiển thị ratio đó và ẩn viền amber; nhấn RESET thì trả ảnh về ratio mặc định lúc load.
|
||||
|
||||
**Hai trạng thái:**
|
||||
|
||||
| Trạng thái | Điều kiện | Preview |
|
||||
| --- | --- | --- |
|
||||
| Đang frame (band) | `cropRatio` là tỉ lệ, `cropApplied !== cropRatio` | viền amber quanh vùng giữ + mask đen opacity 0.55 bên ngoài |
|
||||
| Đã áp (applied) | `cropApplied === cropRatio` | chỉ còn vùng tỉ lệ, mask đen opacity 1, không viền amber |
|
||||
|
||||
Chọn tỉ lệ khác tự quay về trạng thái band (so sánh `cropApplied === cropRatio`, không cần reset thủ công).
|
||||
|
||||
**Sửa:**
|
||||
|
||||
- `App.tsx`: thêm state `cropApplied`; truyền `cropApplied` cho `Viewfinder` và `AdjustmentPanel`, thêm `onApplyCrop={() => setCropApplied(cropRatio)}`; `handleResetAll` thêm `setCropApplied(null)` cạnh `setCropRatio('none')`; `handleUpdateFrame` xoá `cropApplied` khi frame được chọn.
|
||||
- `AdjustmentPanel.tsx`: chip `APPLY` nằm trong hàng chip của `case 'frame'`, chèn ngay sau chip nhóm `CROP`:
|
||||
`cropEnabled && cropRatio !== 'none' && cropRatio !== 'free' && cropApplied !== cropRatio`.
|
||||
- `Viewfinder.tsx`: `libCropView` (`k = min(vw/dw, vh/dh)`, rect căn giữa) + `cropScreenPx` (dùng làm vùng mask khi đã áp, vẫn `cropWinPx` cho frame/grain/vignette); `libViewMatrix` ghép ngoài·trong `[k·s, 0, k·tx + s·ox, 0, k·s, k·ty + s·oy, 0, 0, 1]` áp cho hai `<Group>` của ảnh library; `libCropTouchStyle` (translate rồi scale `k`, có hiệu chỉnh tâm) trên lớp bắt touch; mask `opacity={libCropView ? 1 : 0.55}`; Rect stroke `#f59e0b` bọc trong `{!libCropView && (...)}`.
|
||||
|
||||
**Đo trên máy** (emulator-5554, 1080×2400, ảnh thư viện dọc):
|
||||
|
||||
| Bước | Chip APPLY | Amber trong preview | Vùng nội dung |
|
||||
| --- | --- | --- | --- |
|
||||
| FRAME, CROP = NONE | không | 0 | ảnh đầy đủ (baseline) |
|
||||
| chọn 1:1 | có | 14218 px, band 1080×1083 tại y 343–1426 | ảnh đầy đủ + dim |
|
||||
| APPLY 1:1 | không | 0 | mask đen y 273–344 và 1425–1769; nội dung y 345–1424 |
|
||||
| chọn 2:3 | có | band 928×1340 tại x 76–1003 | `k = 1,164` |
|
||||
| APPLY 2:3 | không | 0 | nội dung phóng kín chiều ngang |
|
||||
| RESET sau APPLY | không | 0 | trùng baseline: chỉ 30/288000 pixel khác, đều ở đồng hồ (99–118, 56–83) |
|
||||
| đổi tỉ lệ sau APPLY (3:4) | có lại | band 17836 px | tự về trạng thái band |
|
||||
|
||||
Với 1:1, `k = 1` nên pixel trong vùng crop của bản APPLY giống hệt bản band (max diff 0) — chỉ mask đổi 0.55 → 1 và viền amber mất.
|
||||
|
||||
**Kiểm tra khác:**
|
||||
|
||||
- `RESET *` → `RESET` sau khi reset (hết dấu *).
|
||||
- Kéo 1 ngón ở preview (150 px và 500 px): ảnh không dịch — kể cả khi không crop, khi band, khi đã APPLY. Pan 1x vốn không chạy trong bản này nên không phải hồi quy của touch layer; `libCropTouchStyle` không gây lệch.
|
||||
- `tsc --noEmit` vẫn **14 lỗi** đúng baseline (1 `App.tsx` + 13 `src/components/Viewfinder.tsx`).
|
||||
- Build `gradlew.bat --offline assembleRelease` → BUILD SUCCESSFUL; `adb install -r` → Success.
|
||||
|
||||
## 19. Lưu `cropApplied` vào session (9/9/2026)
|
||||
|
||||
Trước: APPLY chỉ là state trong bộ nhớ. Cold start / restore LAST SESSION thì mất, preview trở lại ảnh đầy đủ trong khi chip CROP vẫn giữ ratio cũ.
|
||||
|
||||
**Sửa:**
|
||||
|
||||
- `App.tsx`: thêm `cropApplied` vào snapshot session (cạnh `cropRect`), thêm `cropApplied` vào dependency array của effect lưu session, và khi restore: `if (session.cropApplied && session.cropApplied === session.cropRatio) setCropApplied(session.cropApplied);` (guard bước 2: chỉ nhận khi ratio khớp, tránh restore lệch cặp).
|
||||
- Chuẩn hoá 4 dòng LF-only ở `Viewfinder.tsx` 512–515 (comment Task 2) về CRLF.
|
||||
|
||||
**Đo trên máy** (emulator-5554, STARTUP MODE = LAST SESSION):
|
||||
|
||||
| Bước | Chip APPLY | Amber trong preview | Preview |
|
||||
| --- | --- | --- | --- |
|
||||
| load ảnh → CROP 1:1 → APPLY → force-stop → mở lại | không | 0 px | mask đen đặc, nội dung y 398–1477 (viewport cao hơn khi deck đóng) |
|
||||
|
||||
Rect nội dung khác lúc strip đang mở (y 345–1424) là do viewport cao hơn, không phải lỗi.
|
||||
|
||||
- `tsc --noEmit` vẫn **14 lỗi** đúng baseline.
|
||||
- Build `assembleRelease` → BUILD SUCCESSFUL; `adb install -r` → Success.
|
||||
- Đã trả STARTUP MODE về DEFAULT sau khi test.
|
||||
|
||||
## 20. EXIF + tên file `RC_<ts>` khi chụp và khi export (9/9/2026)
|
||||
|
||||
Trước: ảnh lưu ra `camrecipe_pro_export_<epoch>.jpg`, không có EXIF nào ngoài thứ Skia còn giữ được sau re-encode (thực tế là mất sạch).
|
||||
|
||||
**Sửa:**
|
||||
|
||||
- `src/utils/exifWrite.ts` (mới, thuần, không import): dựng lại segment APP1/EXIF từ byte JPEG — đọc TIFF của ảnh nguồn, giữ mọi tag module không sở hữu, ghi đè `Software 0x0131` = `RecipesCam 1.2`, `DateTime 0x0132` / `DateTimeOriginal 0x9003` / `DateTimeDigitized 0x9004`, `Orientation 0x0112` = 1, `ImageWidth/Length 0x0100/0x0101`, `PixelX/YDimension 0xa002/0xa003`, resolution khi có dpi, GPS (0x0001–0x0004). Bỏ IFD1/thumbnail, MakerNote và con trỏ IFD cũ. Độ dài APP1 ghi **big-endian** (khác khối TIFF bên trong — bug đầu tiên của module).
|
||||
- `src/utils/photoMeta.ts` (mới): `rcPhotoName(exported)` → `RC_<yyyymmdd_hhmmss>[_exported].jpg`; `nextPhotoPath()` cộng 1000 ms khi tên đã tồn tại (tránh trùng tên và trùng cache `<Image>`).
|
||||
- `src/utils/exportEngine.ts`: thay `nextExportFile()` bằng `nextPhotoPath(options.exported)`, thêm bước 9c gọi `writeJpegExif` với `capturedAt`/GPS/dims/dpi và byte của ảnh nguồn.
|
||||
- `src/utils/nativeExport.ts`: cùng tên file, `stampExifOnFile()` sau `processPhotoAsync` (không truyền width/height — JS không biết kích thước Kotlin render).
|
||||
- `App.tsx`: `capturedAt = Date.now()` ngay trước khi chụp; `exported: false` cho camera, `exported: true` cho cả 2 nhánh option của library.
|
||||
|
||||
Ảnh camera truyền `dateTime` = thời điểm bấm shutter nên EXIF là lúc chụp; ảnh library truyền `null` nên giữ nguyên ngày gốc của file đã load. Khi `sourceJpeg` không đọc được (uri `content://`, lỗi IO) module lấy chính khối EXIF của file đang ghi làm nền, không xoá tag.
|
||||
|
||||
**Đo trên máy** (emulator-5554, 1080×2400):
|
||||
|
||||
| Thao tác | File | EXIF đọc lại bằng PIL |
|
||||
| --- | --- | --- |
|
||||
| chụp (GPS watermark OFF) | `RC_20260911_175607.jpg` | Software `RecipesCam 1.2`, DateTime/DateTimeOriginal 17:56:06, Orientation 1, 960×1280, giữ Make/Model của camera HAL |
|
||||
| load `RC_libtest.jpg` → export | `RC_20260911_175905_exported.jpg` | giữ Make `OldCam`, Model `X100`, ISO 200, ExposureTime 1/125, FNumber 2.8, FocalLength 35, DateTime 2019:05:04 03:02:01, GPS 48.8584/2.2945; Software `RecipesCam 1.2`; Orientation 1 |
|
||||
|
||||
Emulator không có fix GPS (`getCurrentGPS` → `ERR_CURRENT_LOCATION_IS_UNAVAILABLE` dù `adb emu geo fix` trả OK và `location_mode=3`), nên nhánh ghi GPS từ thiết bị chỉ kiểm được ở mức codec: `exifchk/verify.py` đọc lại đúng lat/lon do `writeJpegExif` ghi (10.776889/106.700806) và PASS.
|
||||
|
||||
- `tsc --noEmit` vẫn **14 lỗi** đúng baseline.
|
||||
- Build `assembleRelease` → BUILD SUCCESSFUL; `adb install -r` → Success.
|
||||
## 21. EXIF chuẩn theo ảnh mẫu IMG_20260830_140429.jpg (2026-09-11)
|
||||
|
||||
Ảnh mẫu (Xiaomi 14 Ultra, 4096×3072, 7 165 975 B) đọc bằng parser tự viết: byte-order `MM`
|
||||
(big-endian), APP1/Exif là segment **đầu tiên** ngay sau SOI (không có APP0/JFIF), TIFF gồm 4 IFD:
|
||||
IFD0 (17 entry) → Exif IFD (43 entry) → GPS IFD (11 entry) → Interop IFD (`0xa005` =
|
||||
{`0x1` `'R98'`, `0x2` `'0100'`}), rồi IFD1 (thumbnail) nối sau IFD0. Tag nằm rải ở cả IFD0 lẫn
|
||||
Exif IFD chứ không gom một chỗ, và **không sắp theo thứ tự tăng dần**.
|
||||
|
||||
Đối chiếu file app ghi ra: tập tag IFD0/GPS khớp mẫu, nhưng thiếu Interop IFD, thiếu
|
||||
`OffsetTime`/`OffsetTimeOriginal`/`OffsetTimeDigitized`, và khi HAL nghèo tag thì thiếu
|
||||
`ExifVersion`/`FlashPixVersion`/`ComponentsConfiguration`/`ColorSpace` — đúng nhóm mà phần mềm
|
||||
tạo khung dùng để nhận dạng thông số.
|
||||
|
||||
`src/utils/exifWrite.ts`:
|
||||
- Thêm IFD thứ 4 (Interop) vào layout + con trỏ `0xa005` trong Exif IFD; parse đọc Interop của
|
||||
nguồn qua `0xa005`.
|
||||
- Điền mặc định khi nguồn thiếu: `ExifVersion 0x9000` = `'0230'`, `FlashPixVersion 0xa000` =
|
||||
`'0100'`, `ComponentsConfiguration 0x9101` = {1,2,3,0}, `ColorSpace 0xa001` = 65535, Interop
|
||||
`'R98'`/`'0100'`. Nguồn có sẵn thì giữ nguyên, không ghi đè.
|
||||
- Ghi `OffsetTime 0x9010` (IFD0) + `OffsetTimeOriginal 0x9011` + `OffsetTimeDigitized 0x9012`
|
||||
(Exif IFD) bằng offset cục bộ `±HH:MM` khi app sở hữu mốc thời gian (ảnh chụp). Ảnh export giữ
|
||||
ngày của file nguồn thì không bịa offset.
|
||||
- Fix bug thật: `swapToLittleEndian` dùng `data.slice()` — với Node `Buffer`, `slice()` trả về
|
||||
*view* chứ không copy, nên hàm swap ngược byte của chính buffer nguồn (đọc ra ISO 36865 thay vì
|
||||
400 khi nguồn big-endian). Nay dùng `new Uint8Array(data)`.
|
||||
- Vẫn chủ động bỏ IFD1/thumbnail và MakerNote (ponytail comment: offset tuyệt đối không sống sót
|
||||
khi TIFF bị dời; thumbnail mang pixel trước khi export).
|
||||
|
||||
**Đo lại** (codec Node + PIL + máy thật):
|
||||
|
||||
| Kiểm chứng | Kết quả |
|
||||
| --- | --- |
|
||||
| `exifchk/run.js` | SELF-CHECK PASSED, gồm check "source bytes left untouched" |
|
||||
| `exifchk/verify.py` | PIL VERIFY PASSED (thêm Interop/ExifVersion/OffsetTime/ColorSpace/ComponentsConfiguration) |
|
||||
| round-trip ảnh mẫu qua writer | mọi tag Xiaomi giữ nguyên: ISO 400, ExposureProgram 2, MeteringMode 2, FocalLengthIn35mmFilm 75, GPS 15°23'54.67"N 108°19'22.47"E; thêm Interop |
|
||||
| chụp `RC_20260911_182721.jpg` (emulator) | Interop `R98`/`'0100'`; `0x9010`/`0x9011`/`0x9012` = `+07:00`; `ComponentsConfiguration`, `FlashPixVersion`, `ColorSpace` có mặt; Software `RecipesCam 1.2`; Orientation 1; 960×1280; giữ Make/Model HAL |
|
||||
|
||||
`ExifVersion` của ảnh chụp trên emulator vẫn là `'0210'` vì HAL đã cung cấp — đúng thiết kế: chỉ
|
||||
điền khi nguồn thiếu.
|
||||
|
||||
**Không thể khắc phục**: nếu phần mềm tạo khung cần tag riêng của nhà sản xuất (MakerNote) hoặc tra
|
||||
cứu cơ sở dữ liệu thiết bị theo Make/Model, app không giả được — MakerNote của máy khác chứa offset
|
||||
tuyệt đối không dùng lại được, còn Make/Model là do HAL trả về.
|
||||
|
||||
- `tsc --noEmit` vẫn **14 lỗi** đúng baseline.
|
||||
- Build `assembleRelease` → BUILD SUCCESSFUL; `adb install -r` → Success.
|
||||
|
||||
|
||||
---
|
||||
|
||||
## 22. Khung dấu chìm Xiaomi: vì sao Gallery báo "không thể nhận dạng được thông số" (2026-09-11)
|
||||
|
||||
**Triệu chứng.** Ảnh chụp `RC_20260911_183114.jpg` mở trong trình edit của Xiaomi, chọn tạo khung
|
||||
dấu chìm → "không thể nhận dạng được thông số". Ảnh `IMG_20260830_140429.jpg` (app camera gốc) thì
|
||||
mở bình thường. Hai ảnh **cùng một máy** (Xiaomi 14 Ultra, codename `aurora`).
|
||||
|
||||
**Nguyên nhân.** HyperOS Gallery đọc tag EXIF **`0x889e`** (Exif IFD, ASCII) để dựng khung dấu
|
||||
chìm — đây là JSON do app camera gốc ghi:
|
||||
|
||||
```
|
||||
0x889e {"buildDevice":"aurora","customize":"","cvLensName":"","filterName":"","livephotoInfo":"",
|
||||
"version":2,"waterLogo":"XIAOMI","waterName":"14 Ultra"}
|
||||
```
|
||||
|
||||
Ảnh mẫu còn có `0x9999` (`{"mirror":false,"sensorType":"rear","Hdr":"auto",...}`) và MakerNote
|
||||
`0x927c` (`Xiaomi\x00\x01MM...`). **Ảnh của app không có** `0x889e`, `0x9999`, `0x927c`, cũng không
|
||||
có GPS IFD.
|
||||
|
||||
Lý do không phải bug của module: nguồn chụp (CameraX/HAL) **không bao giờ** cấp các tag vendor này.
|
||||
HAL chỉ cấp tag chuẩn (ISO 2151, 1/50 s, f/2.0, 8.72 mm, FocalLengthIn35mm 23…). Đã chứng minh
|
||||
module **giữ nguyên** `0x889e`/`0x9999` khi nguồn có (chỉ cố ý bỏ `0x927c`).
|
||||
|
||||
**Diff tag-set** (`exifchk/cmp_exif.py`):
|
||||
|
||||
| | RC_183114 | IMG mẫu |
|
||||
| --- | --- | --- |
|
||||
| SOF0 (w,h) | (3072, 4096) | (4096, 3072) |
|
||||
| byte order | II | MM |
|
||||
| IFD0 / Exif / GPS / Interop | 13 / 36 / 0 / 2 | 17 / 43 / 11 / 2 |
|
||||
| Exif chỉ mẫu có | — | `0x889e` `0x9999` `0x927c` `0x8895` `0x889a` `0x889d` `0x889f` `0xa661` |
|
||||
| IFD0 chỉ mẫu có | — | `0x8825` `0x889a` `0x889d` `0x88a3` `0x9999` `0x9a00` |
|
||||
|
||||
xRes/yRes 300 vs 72, Orientation 1 vs 0, ColorSpace 1 vs 65535, `0x110` Model `24030PN60G` vs
|
||||
`Xiaomi 14 Ultra`. Layout TIFF của ảnh app **hợp lệ**, không lỗi cấu trúc — Gallery chỉ thiếu blob.
|
||||
|
||||
**Fix.** Đọc lại đúng các prop mà app camera gốc dùng rồi tự tổng hợp blob:
|
||||
|
||||
- `modules/recipescam-export/.../RecipescamExportModule.kt`: thêm `Function("deviceInfo")` trả
|
||||
`device` (`ro.product.device`) · `marketName` (`ro.product.marketname` → `ro.product.odm.marketname`
|
||||
→ `ro.product.vendor.marketname`) · `manufacturer` · `model`. Đọc prop hệ thống ẩn bằng reflection
|
||||
(`android.os.SystemProperties.get`), lỗi thì trả chuỗi rỗng.
|
||||
- `src/utils/exifWrite.ts`: `watermarkBlob(device)` dựng JSON **đúng thứ tự field** của ảnh gốc
|
||||
(`waterName` = marketName bỏ tiền tố brand: `Xiaomi 14 Ultra` → `14 Ultra`). Trong
|
||||
`writeJpegExif`, ghi `0x889e` **chỉ khi** nguồn thiếu blob và máy là Xiaomi/Redmi/POCO — **không
|
||||
bao giờ ghi đè** blob nguồn.
|
||||
- `src/utils/photoMeta.ts`: `getDeviceIdentity()` cache một lần, trả `null` khi module cũ.
|
||||
- `src/utils/exportEngine.ts`, `src/utils/photoMeta.ts`: truyền `device` vào stamp.
|
||||
|
||||
**Kiểm chứng.**
|
||||
|
||||
| Kiểm chứng | Kết quả |
|
||||
| --- | --- |
|
||||
| blob sinh ra vs blob gốc (`0x889e`) | **IDENTICAL** từng byte, 146 B + NUL = count 147 |
|
||||
| `exifchk/run.js` | SELF-CHECK PASSED (11 ok) — có check "blob nguồn không bị ghi đè", "không ghi blob trên máy non-Xiaomi" |
|
||||
| `exifchk/verify.py` (PIL) | PIL VERIFY PASSED |
|
||||
| `tsc --noEmit` | 14 lỗi, đúng baseline |
|
||||
| `gradlew --offline assembleRelease` | BUILD SUCCESSFUL |
|
||||
| cài + mở app trên emulator | PID sống, 0 FATAL EXCEPTION |
|
||||
|
||||
**Giới hạn.** Emulator không chạy HyperOS Gallery nên **không** xác nhận trực tiếp được Gallery đã
|
||||
hết lỗi; cũng **không** xác nhận được `deviceInfo()` trả gì trên máy thật (chỉ suy ra từ tên prop
|
||||
chuẩn của Xiaomi). Máy non-Xiaomi sẽ không có blob → vẫn đúng như trước.
|
||||
@@ -0,0 +1,123 @@
|
||||
# Ghi chú port web → Android: sim `CLASSIC VIVIDIPES` + chip FX `MONOCHROME`
|
||||
|
||||
Nguồn: web studio `docker/frontend` (nhánh `recipes-web`), commit `d7d7be2` (sim mới) và
|
||||
`4d0d441` (chip FX). Việc dưới đây là **port sang app Android** (`src/`, nhánh này).
|
||||
|
||||
Đối chiếu: `src/` giữa `recipes-web` và `feat/vision-camera-v5` **chỉ khác**
|
||||
`src/components/RecipeCreateModal.tsx` (+9 dòng) — nên port gần như là chép y nguyên.
|
||||
Số dòng dưới đây tính theo nhánh này.
|
||||
|
||||
---
|
||||
|
||||
## 1. Sim mới `CLASSIC VIVIDIPES`
|
||||
|
||||
Ghép tại dòng blue: **giữ nguyên dòng blue của `classic-chrome`**, lấy **dòng R/G của `velvia`**.
|
||||
|
||||
Sửa 4 file:
|
||||
|
||||
1. `src/types/index.ts:65` — `BaseFilter`: thêm `'classic-vivid'`.
|
||||
2. `src/utils/colorUtils.ts` — thêm nhánh **ngay sau** nhánh `classic-chrome` (dòng 111):
|
||||
```ts
|
||||
} else if (baseFilter === 'classic-vivid') {
|
||||
// Classic Vivid: the Classic Chrome blue row kept verbatim — skies stay the
|
||||
// same muted teal/cyan — with the red and green rows taken from Velvia, so
|
||||
// everything that is not blue reads loud.
|
||||
matrix = [
|
||||
1.3895, -0.2540, -0.0256, 0, -0.0550, // R — velvia
|
||||
-0.0772, 1.2355, -0.0261, 0, -0.0661, // G — velvia
|
||||
0.0652, 0.3973, 0.5575, 0, -0.0100, // B — classic-chrome (nguyên bản)
|
||||
0, 0, 0, 1, 0,
|
||||
];
|
||||
```
|
||||
3. `src/utils/defaultRecipes.ts:43` — `FILM_SIMS`, thêm ngay sau `sim-classic-chrome`:
|
||||
```ts
|
||||
{ id: 'sim-classic-vivid', name: 'CLASSIC VIVIDIPES', baseFilter: 'classic-vivid', adjustments: { ...DEFAULT_ADJUSTMENTS } },
|
||||
```
|
||||
4. `src/utils/toneShader.ts:190` — `FILM_TONE`, cạnh `'classic-chrome'`:
|
||||
```ts
|
||||
'classic-vivid': { sh: -0.28 },
|
||||
```
|
||||
(Dìm shadow thuộc về stock chứ không thuộc dòng ma trận → đi kèm. Bỏ dòng này nếu muốn
|
||||
VIVIDIPES không dìm shadow.)
|
||||
|
||||
**Không phải sửa gì thêm**: chip `PHOTO STYLE` (`AdjustmentPanel.tsx` `groupDefs.style`), TopBar
|
||||
(`activeRecipe.name`) và picker trong `RecipeCreateModal.tsx` đều map từ `FILM_SIMS`.
|
||||
|
||||
**Kiểm chứng**: strip PHOTO STYLE có **10** sim; chọn `CLASSIC VIVIDIPES` → look khác **cả**
|
||||
`CLASSIC CHRIPES` lẫn `VELVIPES`; `CLASSIC CHRIPES` vẫn nguyên vẹn cạnh nó.
|
||||
|
||||
---
|
||||
|
||||
## 2. Chip `MONOCHROME` ở tab FX (`filters`)
|
||||
|
||||
Đây là **công tắc**, không phải một look: bật thì đổi `baseFilter` sang `monochrome`, tắt thì
|
||||
trả lại đúng cái ảnh đang mặc (kể cả tên sim). **Không được đụng `adjustments`** — knob chỉnh
|
||||
trong lúc đang bật phải sống sót.
|
||||
|
||||
Web: `docker/frontend/src/App.tsx` — `monoPrev` ref + `toggleMono`, chip `mono` đứng đầu
|
||||
`case 'fx'`, `active: monoOn` với `monoOn = recipe.baseFilter === 'monochrome'`.
|
||||
|
||||
Android cần 2 chỗ:
|
||||
|
||||
**a. `src/components/AdjustmentPanel.tsx`** — `case 'filters'` (dòng 888):
|
||||
```ts
|
||||
case 'filters':
|
||||
return chipRow([
|
||||
{ key: 'mono', label: 'MONOCHROME', active: monoOn, amberValue: monoOn, onPress: onToggleMono },
|
||||
...paramChips(paramDefs.filters),
|
||||
]);
|
||||
```
|
||||
+ thêm 2 prop vào `interface AdjustmentPanelProps`: `monoOn: boolean;` và `onToggleMono: () => void;`
|
||||
+ truyền từ `App.tsx` chỗ render `<AdjustmentPanel … />` (`App.tsx:1929` có `currentBaseFilter`,
|
||||
`onSelectRecipe` — đặt cạnh đó).
|
||||
|
||||
**b. `App.tsx`** — Android giữ `adjustments` là state **riêng** (không nằm trong `selectedRecipe`),
|
||||
nên công tắc chỉ chạm `selectedRecipe`:
|
||||
|
||||
```ts
|
||||
// FX's MONOCHROME switch: not a look of its own — it swaps the base filter for
|
||||
// the mono stock and swaps right back. Adjustments are never touched.
|
||||
const monoOn = selectedRecipe?.baseFilter === 'monochrome';
|
||||
const monoPrevRef = useRef<Recipe | null>(null);
|
||||
|
||||
const handleToggleMono = () => {
|
||||
commit(true, () => {
|
||||
if (selectedRecipe?.baseFilter === 'monochrome') {
|
||||
const back = monoPrevRef.current ?? filmSimToRecipe(FILM_SIMS.find((f) => f.id === 'sim-provia')!);
|
||||
setSelectedRecipe(back);
|
||||
} else {
|
||||
monoPrevRef.current = selectedRecipe;
|
||||
setSelectedRecipe({
|
||||
...(selectedRecipe ?? filmSimToRecipe(FILM_SIMS[0])),
|
||||
id: 'mono',
|
||||
name: 'MONOCHROME',
|
||||
baseFilter: 'monochrome',
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
```
|
||||
|
||||
Lưu ý:
|
||||
- `commit(true, …)` (dòng 739) để công tắc vào **undo stack** như mọi edit khác — `handleResetAll`
|
||||
dùng đúng cách này.
|
||||
- `Look` đã có `recipe` → UNDO tự trả lại đúng `baseFilter` cũ, không cần state phụ nào khác.
|
||||
- `id: 'mono'` không trùng sim nào nên khi bật, chip PHOTO STYLE **không** còn sáng tên sim cũ
|
||||
(`groupDefs.style.value` tính từ `currentRecipeId` so với `FILM_SIMS`).
|
||||
- Tắt khi `monoPrevRef` rỗng (ví dụ vào monochrome bằng cách chọn sim ACRIPES, không qua công tắc)
|
||||
→ rơi về PROVIA, tức là "tắt là có màu", không phải về đen trắng.
|
||||
|
||||
**Kiểm chứng**: bật → ảnh xám (mọi pixel `R=G=B`; chỉ lệch khi WB khác 5500K hoặc tint khác 0);
|
||||
tắt → ảnh trở lại **y hệt** trước khi bật (so hash pixel); đổi `CONTRAST` trong lúc bật/tắt →
|
||||
giá trị vẫn giữ; UNDO một nhịp → về màu, chip trở lại trạng thái tắt.
|
||||
|
||||
---
|
||||
|
||||
## 3. Test web đã chạy (tham chiếu — nằm trong scratchpad của agent, không nằm trong repo)
|
||||
|
||||
- `sim-vivid-test.cjs` — **9/0**: chip có mặt, strip đủ 10 sim, chọn được, render khác cả
|
||||
CHRIPES lẫn VELVIPES.
|
||||
- `fx-mono-test.cjs` — **15/0**: đo trực tiếp kênh màu trên pixel preview (spread `>25` khi màu,
|
||||
`<=6` khi mono), hash quay lại y hệt khi tắt, CONTRAST sống sót, UNDO gỡ mono.
|
||||
- Regression web quanh 2 thay đổi: `panel`, `layout`, `fonts 27/0`, `create 52/0`, `share 22/0`,
|
||||
`photo-save 19/0`, `redo 30/0`, `accent-brand` — tất cả xanh.
|
||||
@@ -0,0 +1,113 @@
|
||||
# PLAN — Option 3: thay engine export Skia bằng module native Kotlin
|
||||
|
||||
Nhánh: `feat/vision-camera-v5` (RecipesCam, /home/locpham/RecipesCam)
|
||||
Ngày: 2026-09-06. Trạng thái: bắt đầu.
|
||||
|
||||
## Vấn đề
|
||||
`exportEngine.ts` render ~1s/ảnh, sync JSI (`MakeImageFromEncoded`, `makeImageSnapshot` + `encodeToBytes(JPEG,95)`, sharpen) → block JS thread khi queue render chạy. Shutter đã được tách khỏi render bằng FIFO queue (`exportQueueRef`/`enqueueExport`, commit f588849); phần còn lại = render chạy trên thread nền native, JS/UI không bao giờ nghẽn.
|
||||
|
||||
## Quyết định đã chốt (user: "theo khuyến nghị hết")
|
||||
- **D1** Chỉ Android (app không có iOS env).
|
||||
- **D2** v1 = xấp xỉ gần, KHÔNG pixel-identical: ColorMatrix + các bước CPU/Canvas. KHÔNG port GPU AGSL (RuntimeShader) ở v1. Skia engine GIỮ làm reference/fallback, không xoá trước parity gate.
|
||||
- **D3** Vehicle = **local Expo Module** `modules/recipescam-export` (expo-modules-core 57.0.6 có sẵn; autolinking quét `./modules` mặc định — đã xác minh trong expo-modules-autolinking src). Không cần nitrogen/nitro-image cho code mới (nitro-image vẫn nằm trong deps, không dùng).
|
||||
- **D4** Chạy P0 benchmark decode+encode native trên máy Xiaomi trước khi mở rộng pipeline. Nếu CPU path vỡ budget (<~1s/ảnh toàn pipeline, mục tiêu render nền <500ms) thì quay lại cân nhắc GPU.
|
||||
|
||||
## Kiến trúc đích
|
||||
- App gọi module native qua promise → hàm chạy trên background (`AsyncFunction ... Coroutine` + `withContext(Dispatchers.IO)`), JS không block.
|
||||
- FIFO: mỗi job 1 lời gọi module; module tự serialize trên executor nếu cần (v1: JS queue hiện có là đủ, mỗi task 1 await module).
|
||||
- Đầu vào: `sourceUri` file ảnh + tham số recipe (matrix float[], tone params, frameId, geotag text...). Đầu ra: file JPEG (path) hoặc bytes. MediaLibrary save + DPI patch giữ nguyên ở JS.
|
||||
- Legacy engine giữ nguyên file `src/utils/exportEngine.ts`; native path phát triển song song, bật qua flag sau parity gate.
|
||||
|
||||
## Ánh xạ pipeline (thứ tự Skia engine → native)
|
||||
Đọc từ exportEngine.ts (512 dòng) — thứ tự xử lý bắt buộc giữ nguyên để ảnh gần engine cũ:
|
||||
|
||||
| # | Skia (nguồn) | Native thay thế | Parity |
|
||||
|---|---|---|---|
|
||||
| 1 | aspect crop center-largest (trừ wallframe) | Bitmap crop `Bitmap.createBitmap(src, x,y,w,h)` | exact |
|
||||
| 2 | matrix màu: `getSkiaColorMatrix` + `applyExposureGain` → `ColorFilter.MakeMatrix` | port toán colorUtils.ts → Kotlin float[4x5] → `ColorMatrixColorFilter` | exact nếu giữ thứ tự phép nhân & rounding; test diff |
|
||||
| 3 | tone `TONE_SKSL` DR/highlight/shadow (toneShader.ts) | v1: đường cong LUT 1D/luminance xấp xỉ | approx |
|
||||
| 4 | cinema `CINEMA_SKSL` seasonal grade | v1: bỏ qua hoặc matrix approx | approx (chú thích) |
|
||||
| 5 | denoise blur | v1: skip / downscale-upscale blur | approx |
|
||||
| 6 | clarity 3x3 conv sharpen (âm = mist blur) | v1: conv nguyên thuỷ trên IntArray nếu >0; mist = blur | approx (watch perf) |
|
||||
| 7 | grain overlay noise shader BlendMode.Overlay | noise bitmap tile + PorterDuff Overlay | approx |
|
||||
| 8 | frames: polaroid card (`POLAROID_CARD`/`polaroidLayout`), wallframe (wallframe.png, xoay 90, `wallframeLayout`), classic borders (`drawFrameOnCanvas`) | Android Canvas draw; port layout constants từ frameUtils.ts | close |
|
||||
| 9 | GPS watermark: font Cousine + NotoEmoji-GPS, 📍/📷, amber #f59e0b/trắng, vị trí tuỳ framedWindow | Canvas + `Typeface.createFromFile` | close (metrics emoji khác Skia → QA) |
|
||||
| 10 | screen sharpen (`screenSharpenImage`) | v1: conv 3x3 native hoặc skip | approx |
|
||||
| 11 | encode `encodeToBytes(JPEG,95)` | `Bitmap.compress(JPEG, 95)` | exact (size có thể lệch nhẹ, chấp nhận) |
|
||||
| 12 | DPI patch `patchJpegDpi(bytes,300)` (jpegDpi.ts) | GIỮ ở JS trên bytes/phản hồi | exact |
|
||||
| 13 | ghi file + `MediaLibrary.createAssetAsync` (App.tsx) | giữ nguyên JS | exact |
|
||||
|
||||
Nguồn hằng số: src/utils/colorUtils.ts (matrices provia/velvia/classic-chrome/astia/eterna/classic-neg/leica/monochrome, kelvinToRGB, WB/tint, CC), src/utils/toneShader.ts, src/utils/cinemaShader.ts, src/utils/frameUtils.ts (POLAROID_CARD, polaroidLayout, WALLFRAME_W/H 3117/4000, wallframeLayout), src/utils/exportEngine.ts, src/utils/jpegDpi.ts.
|
||||
|
||||
## Phases
|
||||
|
||||
### P0 — Spike: module skeleton + benchmark decode/encode native
|
||||
Việc:
|
||||
- Tạo local Expo module `modules/recipescam-export` (android only) với 1 hàm `decodeEncodeAsync(srcPath, dstPath, quality)` → Map{decodeMs, encodeWriteMs, totalMs}; decode BitmapFactory, encode Bitmap.compress, ghi file, toàn bộ `withContext(Dispatchers.IO)`.
|
||||
- Probe tạm `src/dev/nativeBenchProbe.ts` + hook App.tsx gated `process.env.EXPO_PUBLIC_BENCH === '1'` (chạy 3 lần trên wallframe.png + đo gap setInterval để chứng minh JS không block).
|
||||
- Build release với `EXPO_PUBLIC_BENCH=1 ./gradlew assembleRelease` → APK.
|
||||
- Chạy trên Xiaomi (USB), đọc logcat ReactNativeJS.
|
||||
Exit: decode+encode < ~500ms; JS gap nhỏ khi render. Đây là gate D4.
|
||||
File sinh: modules/recipescam-export/*, src/dev/nativeBenchProbe.ts (xoá khi P7).
|
||||
|
||||
### P1 — Port matrix màu
|
||||
- Kotlin: dịch `getSkiaColorMatrix` + `applyExposureGain` (colorUtils.ts) → FloatArray 4x5. Tham số baseFilter + ColorAdjustments truyền từ JS (hoặc gửi matrix đã tính từ JS — JS tính nhanh, matrix 4x5 ~ 20 số — QUYẾT ĐỊNH: tính matrix ở JS bằng colorUtils.ts hiện có rồi truyền float[] xuống native → KHÔNG nhân đôi logic, parity miễn bàn). Cùng kiểu: kelvinToRGB/WB/CC vẫn nằm trong getSkiaColorMatrix → chỉ cần gửi kết quả.
|
||||
- Native: `processColorAsync(srcPath, dstPath, matrix: FloatArray, crop: {…}|null)` decode → crop → `ColorMatrixColorFilter` qua Paint/Canvas draw → encode.
|
||||
Exit: ảnh ra khớp Skia matrix (so sánh bằng mắt + diff).
|
||||
|
||||
### P2 — Tone (DR/HL/SH) xấp xỉ
|
||||
- Port công thức toneShader.ts sang dạng LUT áp per-pixel (hoặc 2 pass nếu cần giữ grain sau tone — thứ tự: tone trước clarity/grain như engine).
|
||||
Exit: vùng highlight/shadow không cháy như engine cũ, chấp nhận sai số.
|
||||
|
||||
### P3 — Clarity/grain/denoise/cinema
|
||||
- Theo bảng: conv 3x3 IntArray (clarity), blur xấp xỉ (denoise/mist), noise tile Overlay (grain). Cinema: quyết định giữ matrix approx hay bỏ.
|
||||
Exit: ảnh gần engine cũ, tổng thời gian nền OK (đo trên máy).
|
||||
|
||||
### P4 — Frames + watermark (Canvas)
|
||||
- polaroid card, wallframe (asset wallframe.png — đọc qua đường file/asset), classic borders, GPS text/emoji.
|
||||
Exit: layout khớp frameUtils (kiểm tra pixel các góc/viền bằng overlay ảnh so sánh).
|
||||
|
||||
### P5 — Hook vào luồng export thật + flag
|
||||
- App.tsx: thêm nhánh `useNativeExport` (flag hằng/env); giữ nguyên enqueueExport FIFO; MediaLibrary/DPI giữ nguyên.
|
||||
Exit: chụp → gallery có ảnh qua native path.
|
||||
|
||||
### P6 — Parity + perf + fallback
|
||||
- Script chạy cùng 1 ảnh nguồn qua 2 engine; xuất ảnh ghép cạnh nhau + điểm diff (downscale) để QA trên máy.
|
||||
- Đo: cũ ~1s/ảnh block JS → mới <500ms nền.
|
||||
- Giữ legacy: `processAndExportPhoto` cũ nguyên vẹn, bật qua cờ.
|
||||
Exit: user QA đạt → mặc định native; legacy chỉ fallback.
|
||||
|
||||
### P7 — Dọn & ship
|
||||
- Xoá probe/dev code, xoá env gate; typecheck; commit + push (KHÔNG stage `.kilo/`, `.expo/`); `assembleRelease`; báo MD5 APK.
|
||||
- Nhắc: nếu npm install chạy lại → node_modules vision-camera về pristine, áp lại patch:
|
||||
`cd RecipesCam && git apply patches/vision-camera-shutter-silent.patch`
|
||||
(artifact = diff `HybridPhotoOutput.kt`: shutter OFF thật silent — bỏ OR mustPlayShutterSound).
|
||||
`cd RecipesCam && git apply patches/vision-camera-isp-off.patch`
|
||||
(artifact = 3 hunk, 2 file: `HybridPhotoOutput.kt` cho mọi still JPEG, `modules/recipescam-ultrawide/.../UltraWideModule.kt` cho đường 0.5x — cùng cặp cờ NOISE_REDUCTION_MODE + EDGE_MODE của ISP = OFF).
|
||||
|
||||
## Rủi ro / ghi chú
|
||||
- Local module phải bắt chước đúng cấu trúc expo package (đã đọc expo-media-library: `plugins { id 'com.android.library'; id 'expo-module-gradle-plugin' }`, expo-module.config.json khai báo class Kotlin; autolinking quét `./modules`).
|
||||
- API expo-modules-core 57: `AsyncFunction("x") Coroutine { }` (như MediaLibraryModule), coroutines 1.10.2 qua `api` của expo-modules-core.
|
||||
- Gradle: thêm module → lần build đầu sẽ reconfigure (chậm hơn), không cần sửa settings.gradle.
|
||||
- 12MP full-res decode ~48MB RAM — ổn, không inSampleSize.
|
||||
- Emoji trong Canvas có metrics khác Skia — kiểm tra vị trí watermark.
|
||||
- Grain procedural (Skia hash noise) ≠ noise tile — chấp nhận sai khác nhỏ ở v1.
|
||||
- Không đặt tên module trùng class đã có.
|
||||
|
||||
## Checklist trạng thái
|
||||
- [x] Chốt quyết định D1–D4
|
||||
- [x] P0 scaffold: module `modules/recipescam-export` + probe `src/dev/nativeBenchProbe.ts` + hook App.tsx (env-gated); release build OK (recipescam-export 0.1.0 autolink)
|
||||
- [x] P0 benchmark trên Xiaomi (12MP wallframe: decode 152-182ms, encode+write 56-77ms, total 215-269ms, off-main-thread — PASS)
|
||||
- [x] P1 matrix (processColorAsync: decode→crop→ColorMatrixColorFilter→JPEG; parity OK trên Xiaomi — classic-neg/black → rgb(13,5,0), translate×255, crop 2000x2000, off-thread, colorMs≈30ms)
|
||||
- [x] P2 tone (applyTone CPU pass port TONE_SKSL — immutable-decode fix: copy ARGB_8888 trước setPixels; gates 64x64 trên Xiaomi: white hl=-1→140, gray64 sh=+1→75, white dr=1→209, gray26 sh=+1→104, gray26 hl=+1 sh=-1→11 — ALL PASS; full 12MP toneMs 488-548ms = cần tối ưu khi gộp P3)
|
||||
- [x] P3 clarity/grain (native denoise/clarity/grain/cinema port: boxBlur padding fix + step gates; cinema gates summer-192→(211,209,201), winter-32→(82,85,88), summer-sat→(217,120,47) khớp node ±0; ramp/step blur gates denoise→130, clarity→131, mist→130/denoise-step→170, mist-step→142 PASS; grain structural OK; full 12MP 4.7-5.1s CPU = vượt D4, quyết GPU ở P6)
|
||||
- [x] P4 frames/watermark
|
||||
- [x] P5 hook export + flag
|
||||
- [ ] P6 parity/perf gate — probe chạy xong trên Xiaomi (1fdf765): classic-neg / retro-amber-frame / cinema-summer trên ảnh real 2304x3072; native off-thread 2.7-2.9s (JS ticks 65-70 vẫn chạy) vs legacy GPU 0.36-0.5s (JS-block); diff meanAbs 7.1/13.1/13.3, gt12% 16.8/42.1/42.7, gt32% 0.14/3.7/4.0 (grain procedural + JPEG encoder + tone approx; retro thấp nhất — mist + frame classic). CHỜ user QA ảnh ghép DCIM/parity_*.jpg → flip default native + quyết GPU
|
||||
- [ ] P7 dọn + ship
|
||||
|
||||
## Ngoài P-series (feature branch)
|
||||
- [x] Startup mode DEFAULT/LAST SESSION (Settings chip + AsyncStorage `startup_mode`; restore session qua FILM_SIMS; fix clobber ratio khi sim selected — 3 file LF)
|
||||
- [x] GPS watermark mặc định OFF khi startup DEFAULT (applyDefaultStartup setUseGeotag(false)) — verify UI Xiaomi: cold start DEFAULT → FRAME tab GPS WATERMARK OFF
|
||||
- [x] Shutter OFF thật silent (patch HybridPhotoOutput.kt, 2 path, artifact patches/) — verify log ON/OFF trên Xiaomi
|
||||
- [ ] Zoom camera native: probe physical zoom + zoom UI (đang làm)
|
||||
@@ -0,0 +1,64 @@
|
||||
# Play release notes
|
||||
|
||||
Text for Google Play Console → Release → "What's new". One entry per version, newest
|
||||
first. Plain text only (the Console renders no markdown), 500 characters max per
|
||||
language — each block below is measured and fits. Paragraphs are one line each so a
|
||||
copy-paste lands in the Console unwrapped.
|
||||
|
||||
Release name field: `1.2.7 (9)`
|
||||
|
||||
## 1.2.7 (versionCode 9)
|
||||
|
||||
```
|
||||
Photos taken in dim light and at high ISO came out much noisier than the preview showed. Every shot was being sharpened in the background by a fixed amount the viewfinder never applied, and it amplified sensor noise about three times over.
|
||||
|
||||
That hidden sharpening is gone, so high-ISO shots now come back as clean as the preview promised. The SHARPENING slider still sharpens exactly as before when you move it above 0.
|
||||
```
|
||||
|
||||
419 characters (1.2.7).
|
||||
|
||||
Artifact: `android/app/build/outputs/bundle/liteRelease/app-lite-release.aab`
|
||||
(appId `com.locphamtran.recipescamera`, versionCode 9, versionName 1.2.7).
|
||||
The pro APK built from the same commit is `app-pro-release.apk`, versionName
|
||||
`1.2.7-pro` - it is the paid build handed out directly, not uploaded here.
|
||||
|
||||
## 1.2.6 (versionCode 8)
|
||||
|
||||
```
|
||||
New film simulation B&W HIGH CONTRAST, next to ACRIPES: a warm-filter black-and-white mix, with deeper blacks and lifted whites, so the greys take a harder push.
|
||||
Its contrast comes from the stock itself, so the EXPOSURE and CONTRAST sliders stay at 0 while the shot renders pushed.
|
||||
```
|
||||
|
||||
281 characters (1.2.6).
|
||||
|
||||
Artifact: `android/app/build/outputs/bundle/liteRelease/app-lite-release.aab`
|
||||
(appId `com.locphamtran.recipescamera`, versionCode 8, versionName 1.2.6).
|
||||
The pro APK built from the same commit is `app-pro-release.apk`, versionName
|
||||
`1.2.6-pro` - it is the paid build handed out directly, not uploaded here.
|
||||
|
||||
## 1.2.5 (versionCode 7)
|
||||
|
||||
```
|
||||
Film simulations are now pure colour and tone: each one is the stock's own colour matrix and curve, with no grain, clarity, exposure or saturation baked in - those are your edits to make.
|
||||
LC STREETLIFE VIVID keeps its brightness from the stock itself, so the EXPOSURE slider stays at 0 while the shot comes out lifted.
|
||||
```
|
||||
|
||||
Artifact: `android/app/build/outputs/bundle/liteRelease/app-lite-release.aab`
|
||||
(appId `com.locphamtran.recipescamera`, versionCode 7, versionName 1.2.5).
|
||||
The pro APK built from the same commit is `app-pro-release.apk`, versionName
|
||||
`1.2.5-pro` - it is the paid build handed out directly, not uploaded here.
|
||||
|
||||
## 1.2.4 (versionCode 6)
|
||||
|
||||
```
|
||||
HSL selective colour is here: eight hue bands (red, orange, yellow, green, aqua, blue, purple, magenta), each with its own HUE, SAT and LUM, plus HUE/SAT/LUM IMAGE knobs that move every colour at once.
|
||||
In the library, tap PICK and touch any colour on the photo: a panel opens on that spot with the three knobs of the band it belongs to.
|
||||
The tool rail is reordered: presets, favorites, white balance, light, effects, HSL, frame.
|
||||
```
|
||||
|
||||
427 characters (1.2.4).
|
||||
|
||||
Artifact: `android/app/build/outputs/bundle/liteRelease/app-lite-release.aab`
|
||||
(appId `com.locphamtran.recipescamera`, versionCode 6, versionName 1.2.4).
|
||||
The pro APK built from the same commit is `app-pro-release.apk`, versionName
|
||||
`1.2.4-pro` — it is the paid build handed out directly, not uploaded here.
|
||||
@@ -0,0 +1,67 @@
|
||||
# Release notes
|
||||
|
||||
## 1.2.7 (versionCode 9)
|
||||
|
||||
Export
|
||||
- Every camera capture came out of the exporter through a full-resolution unsharp mask the viewfinder never showed. App.tsx passed `sharpen: true`, and with the SHARPENING knob at its default 0 both engines read that flag as an *amount* of 0.5 - a value that existed only as the boolean's fallback. The 3x3 kernel `[0,-a,0; -a,1+4a,-a; 0,-a,0]` at a=0.5 has a noise gain of 3.167x at low frequency and 5.0x at Nyquist, so a dim, high-ISO frame was pushed back several times noisier than the preview promised.
|
||||
- The knob is now the only source of output sharpening. The boolean is gone from ExportOptions and NativeExportOptions, the 0.5 fallback is gone with it, and screenSharpenImage no longer carries `amount = 0.5` as a default argument - a second door onto the same value. A knob above 0 sharpens exactly as before, the negative knob still means none, and the Kotlin-side `adjust["sharpen"]` amount is untouched.
|
||||
- Measured on a Xiaomi thor, 3000x4000 captures in one unchanged scene. At 1x / ISO ~330 the noise sigma drops 4.52 -> 2.35, the dark-quartile sigma 3.21 -> 1.56 and a flat 64px tile 0.599 -> 0.349; at 10x / ISO ~1900 the same three fall 1.22 -> 1.09, 0.75 -> 0.54 and 0.464 -> 0.225. With no edit applied, the JPEGs shrink 26% and 35% - the mask had also been spending a third of every file on amplified noise.
|
||||
- Stills now match the preview, which always rendered without the mask.
|
||||
|
||||
Notes
|
||||
- qualityPrioritization was re-tested as the other suspect and cleared: MAXIMIZE_QUALITY ('quality') moved neither noise figure at either ISO, so the shipped 'balanced' capture mode stays.
|
||||
- Zoom to 10x in the same room is the high-ISO bench for this device: AE picks ISO ~1900-2100 there against ~330 at 1x.
|
||||
|
||||
## 1.2.6 (versionCode 8)
|
||||
|
||||
Presets
|
||||
- New film sim B&W HIGH CONTRAST, next to ACRIPES. Acros' grey ramp taken through a warm filter mix - red 0.38 / green 0.56 / blue 0.06, the three rows equal and summing to 1.00, so a neutral stays the neutral the sensor recorded and nothing clips at either end - and then pushed at both ends: the toe goes to -0.32 against Acros' -0.12, and the shoulder LIFTS to +0.26 instead of rolling off. Midtones see neither mask, so the long smooth stretch between the two ends survives, which is what keeps that hard a push off the posterised look.
|
||||
- The stock's own slope (+4 of Contrast) is not in the sim's adjustments: the panel still reads EXPOSURE 0 / CONTRAST 0, and FX still reads GRAIN 0 / CLARITY 0, while the render comes out pushed. It rides STOCK_BIAS in colorUtils.ts, keyed by baseFilter - the old STOCK_EXPOSURE table folded in, one place for the tone a stock carries by itself (LC STREETLIFE VIVID exposure +2, B&W HIGH CONTRAST contrast +4).
|
||||
- Two black-and-white stocks exist now, so every "is this a colour stock?" test goes through the new isMonochromeBase(): saturation, white balance and the R/B fine-tune in the matrix, Color Chrome in the tone shader. A second B&W stock can no longer end up half excluded - a stray Kelvin gain on a grey ramp shows up as a tint.
|
||||
- Measured against the compiled tables: a 0.25 grey goes 0.2500 -> 0.2250 (the +4 slope pivoting on mid-grey 0.5), a full red goes 0.213 -> 0.368 while blue drops to 0.016, and colour-stage knobs moved hard (saturation +8, white balance 9000K, wbR +5 / wbB -5) leave both B&W matrices byte-identical.
|
||||
- On a Xiaomi thor: the chip sits right after ACRIPES, its preview reads mean chroma 0.00 against 17.48 for a colour sim, and the frame's luma spread widens as designed (sd 92.3 / p95 245 against ACRIPES' 88.9 / 238).
|
||||
|
||||
## 1.2.5 (versionCode 7)
|
||||
|
||||
Presets
|
||||
- A film sim is a stock, not an edit: every entry in FILM_SIMS now carries the neutral defaults verbatim, and its whole look comes from the stock's own colour matrix plus its FILM_TONE curve. The effects that had been riding along are gone - grain on ACRIPES, clarity+grain on LC STREETLIFE CLASSIC, and exposure/saturation/clarity/shadow/highlight on LC STREETLIFE VIVID.
|
||||
- LC STREETLIFE VIVID is brighter because the stock is brighter, not because a slider was moved: the +2 exposure lives in STOCK_EXPOSURE (colorUtils.ts), keyed by baseFilter, where the preview and both exporters already read it. It never reaches the panel, so EXPOSURE still reads 0 on that sim.
|
||||
- The naming rule is recorded in the FILM_SIMS header: colour and tone only, plus a reference table of the real stock each sim stands for. Reference only - the UI keeps the sim names.
|
||||
- Measured against the compiled tables: a mid grey goes 0.5000 -> 0.5435 under LC STREETLIFE VIVID, a ratio of 1.1008 against exposure +2's own 1.1000, while the sim's own exposure value stays 0.
|
||||
|
||||
## 1.2.4 (versionCode 6)
|
||||
|
||||
HSL
|
||||
- New HSL tab in the tool rail: the web studio's 8-band selective colour mixer. Each band (RED, ORANGE, YELLOW, GREEN, AQUA, BLUE, PURPLE, MAGENTA) carries hue, saturation and lightness knobs, -10..+10; -10 on SAT is grey, full HUE is a 30 deg turn, full LUM is +0.25.
|
||||
- Band weights are linear tents anchored on the 8 hues and summing to 1 at every hue, so neighbouring bands hand over at exactly half strength and no hue falls in a gap. Pixels under 8% saturation are gated out of the weights (rgb2hsl calls them red, and an ungated neutral would ride the RED band), while the whole-image LUM move is added after the gate, so a picture pulled to grey by -SAT still answers +LUM.
|
||||
- The mixer runs last in TONE_SKSL, after split-tone/kv and vibrance, so a band edit is judged on the colour the user actually sampled.
|
||||
- Library mode gains an eyedropper: PICK arms a layer over the picture, a tap reads the rendered pixel, points the ruler at the nearest band and hangs a panel with that band's three knobs. In camera mode PICK is greyed out - a live preview has no pixel to read - but the mixer still runs in the preview.
|
||||
- HUE / SAT / LUM IMAGE move every hue at once, behind an IMAGE divider in the same chip row.
|
||||
|
||||
Rail
|
||||
- Tool rail order: PRESETS, FAVORITED, WB, LIGHT, FX, HSL, FRAME.
|
||||
|
||||
Notes
|
||||
- HSL is a LITE feature: both builds get it, no PRO gate.
|
||||
- MONOCHROME disables the mixer outright - a mono stock has no hue to select.
|
||||
- The camera worklet's tone buffer grew from 10 to 40 slots; a missed slot would have pushed NaN into every uniform and broken the whole tone pass, not just HSL.
|
||||
- Measured on a Xiaomi thor against an 8-band chart: +10 whole-image HUE rotates all eight bands by exactly +30 deg, -10 on a band's SAT flattens that band to grey while the other seven stay put, and a whole-image -SAT drops the camera preview's mean chroma from 13.7 to 0.4 with +LUM still lifting it 116 -> 166.
|
||||
|
||||
## 1.2.3 (versionCode 5)
|
||||
|
||||
Shutter
|
||||
- The shutter no longer waits on GPS: the fix is prewarmed at launch and bounded by a 2 s deadline, so a tagged shot reaches the gallery in about 3 s instead of 12–25 s.
|
||||
- A shot pressed before the camera session binds is queued and replayed, not dropped.
|
||||
- Fixes "Capture Failed — Could not take the photo.": `capturePhotoToFile` needs both its settings and options arguments.
|
||||
- Saving to the gallery no longer depends on the media permission — the native save runs first, MediaLibrary only as a fallback.
|
||||
|
||||
Memory
|
||||
- The Skia surface and bitmap are released after every export, dropping one full-resolution copy per rotation; the watermark font is parsed once.
|
||||
|
||||
Cold start
|
||||
- The JS bundle is stored uncompressed in the APK.
|
||||
- Named Lucide imports are rewritten to per-icon modules, so only the icons the app renders are bundled: 4.66 MB → 3.40 MB (−27%).
|
||||
- The export pipeline and the recipe/GPS helpers load on demand.
|
||||
- Hidden modals render nothing.
|
||||
|
||||
Measured on a vivo V2430 (Android 16) with a force-stop/start/tap probe: the earliest tap that reliably lands a photo moved from ~1.8 s to ~1.6 s once the bundle was trimmed. The rest is spent before app JS — process init, SoLoader, Hermes eval.
|
||||
|
After Width: | Height: | Size: 516 KiB |
|
After Width: | Height: | Size: 645 KiB |
@@ -0,0 +1,262 @@
|
||||
# Third-Party Notices
|
||||
|
||||
This app (recipescamera) embeds or depends on the following open-source
|
||||
software. Full license texts are reproduced below, grouped by license.
|
||||
|
||||
Generated 2026-09-09 from node_modules audit, rev. 2026-09-14 — 31 shipped components.
|
||||
|
||||
## Components
|
||||
|
||||
| Component | Version | License | Copyright / Author | Project |
|
||||
|---|---|---|---|---|
|
||||
| Skia graphics library (bundled native in react-native-skia) | bundled | BSD-3-Clause | Copyright 2011 Google Inc. All rights reserved. | https://skia.org/ |
|
||||
| canvaskit-wasm | 0.41.0 | BSD-3-Clause | Copyright (c) 2011 Google Inc. All rights reserved. | https://skia.org/ |
|
||||
| lucide-react-native | 1.24.0 | ISC | Copyright (c) 2026 Lucide Icons and Contributors | https://lucide.dev |
|
||||
| @react-native-async-storage/async-storage | 2.2.0 | MIT | Copyright (c) 2015-present, Facebook, Inc. | https://github.com/react-native-async-storage/async-storage#readme |
|
||||
| @react-native-community/slider | 5.2.0 | MIT | react-native-community | https://github.com/callstack/react-native-slider#readme |
|
||||
| @shopify/react-native-skia | 2.6.2 | MIT | Copyright 2021-present, Shopify Inc. | git+https://github.com/Shopify/react-native-skia.git |
|
||||
| expo | 57.0.7 | MIT | Copyright (c) 2015-present 650 Industries, Inc. (aka Expo) | https://github.com/expo/expo/tree/main/packages/expo |
|
||||
| expo-asset | 57.0.6 | MIT | Copyright (c) 2015-present 650 Industries, Inc. (aka Expo) | https://docs.expo.dev/versions/latest/sdk/asset/ |
|
||||
| expo-dev-client | 57.0.6 | MIT | Copyright (c) 2015-present 650 Industries, Inc. (aka Expo) | https://docs.expo.dev/versions/latest/sdk/dev-client/ |
|
||||
| expo-document-picker | 57.0.2 | MIT | Copyright (c) 2015-present 650 Industries, Inc. (aka Expo) | https://docs.expo.dev/versions/latest/sdk/document-picker/ |
|
||||
| expo-file-system | 57.0.1 | MIT | Copyright (c) 2015-present 650 Industries, Inc. (aka Expo) | https://docs.expo.dev/versions/latest/sdk/filesystem/ |
|
||||
| expo-font | 57.0.1 | MIT | Copyright (c) 2015-present 650 Industries, Inc. (aka Expo) | https://docs.expo.dev/versions/latest/sdk/font/ |
|
||||
| expo-haptics | 57.0.1 | MIT | Copyright (c) 2015-present 650 Industries, Inc. (aka Expo) | https://docs.expo.dev/versions/latest/sdk/haptics/ |
|
||||
| expo-iap | 5.6.0 | MIT | Copyright (c) 2025 hyochan | https://github.com/hyodotdev/openiap/tree/main/libraries/expo-iap#readme |
|
||||
| expo-keep-awake | 57.0.1 | MIT | Copyright (c) 2015-present 650 Industries, Inc. (aka Expo) | https://docs.expo.dev/versions/latest/sdk/keep-awake/ |
|
||||
| expo-location | 57.0.4 | MIT | Copyright (c) 2015-present 650 Industries, Inc. (aka Expo) | https://docs.expo.dev/versions/latest/sdk/location/ |
|
||||
| expo-media-library | 57.0.2 | MIT | Copyright (c) 2015-present 650 Industries, Inc. (aka Expo) | https://docs.expo.dev/versions/latest/sdk/media-library/ |
|
||||
| expo-sharing | 57.0.18 | MIT | Copyright (c) 2015-present 650 Industries, Inc. (aka Expo) | https://docs.expo.dev/versions/latest/sdk/sharing/ |
|
||||
| expo-status-bar | 57.0.1 | MIT | Copyright (c) 2015-present 650 Industries, Inc. (aka Expo) | https://docs.expo.dev/versions/latest/sdk/status-bar/ |
|
||||
| nativewind | 4.2.6 | MIT | Copyright (c) 2023 Mark Lawlor | https://nativewind.dev |
|
||||
| react | 19.2.3 | MIT | Copyright (c) Meta Platforms, Inc. and affiliates. | https://react.dev/ |
|
||||
| react-dom | 19.2.3 | MIT | Copyright (c) Meta Platforms, Inc. and affiliates. | https://react.dev/ |
|
||||
| react-native | 0.86.0 | MIT | Copyright (c) Meta Platforms, Inc. and affiliates. | https://reactnative.dev/ |
|
||||
| react-native-nitro-image | 0.15.2 | MIT | Marc Rousavy <me@mrousavy.com> (https://github.com/mrousavy) | https://github.com/mrousavy/react-native-nitro-image#readme |
|
||||
| react-native-nitro-modules | 0.37.1 | MIT | Marc Rousavy <me@mrousavy.com> (https://github.com/mrousavy) | https://nitro.margelo.com |
|
||||
| react-native-reanimated | 4.5.0 | MIT | Copyright (c) 2016 Software Mansion <swmansion.com> | https://docs.swmansion.com/react-native-reanimated |
|
||||
| react-native-safe-area-context | 5.8.0 | MIT | Copyright (c) 2019 Th3rd Wave | https://github.com/AppAndFlow/react-native-safe-area-context#readme |
|
||||
| react-native-vision-camera | 5.2.3 | MIT | Marc Rousavy <me@mrousavy.com> (https://github.com/mrousavy) | https://visioncamera.margelo.com |
|
||||
| react-native-vision-camera-skia | 5.2.3 | MIT | Marc Rousavy <me@mrousavy.com> (https://github.com/mrousavy) | https://visioncamera.margelo.com/api/react-native-vision-camera-skia |
|
||||
| react-native-vision-camera-worklets | 5.2.3 | MIT | Marc Rousavy <me@mrousavy.com> (https://github.com/mrousavy) | https://visioncamera.margelo.com/api/react-native-vision-camera-worklets |
|
||||
| react-native-web | 0.21.2 | MIT | Copyright (c) Nicolas Gallagher. | git://github.com/necolas/react-native-web.git |
|
||||
| Cousine | Google Fonts | OFL-1.1 | Copyright 2026 The Cousine Project Authors (github.com/googlefonts/cousine) | https://fonts.google.com/specimen/Cousine |
|
||||
| Noto Emoji (GPS subset) | Google Fonts | OFL-1.1 | Copyright 2022 The Noto Project Authors (github.com/notofonts/noto-emoji) | https://fonts.google.com/noto/specimen/Noto+Emoji |
|
||||
|
||||
## BSD-3-Clause
|
||||
|
||||
Applied to: Skia graphics library (bundled native in react-native-skia) (Copyright 2011 Google Inc. All rights reserved.), canvaskit-wasm (Copyright (c) 2011 Google Inc. All rights reserved.)
|
||||
|
||||
```text
|
||||
// Copyright (c) 2011 Google Inc. All rights reserved.
|
||||
//
|
||||
// Redistribution and use in source and binary forms, with or without
|
||||
// modification, are permitted provided that the following conditions are
|
||||
// met:
|
||||
//
|
||||
// * Redistributions of source code must retain the above copyright
|
||||
// notice, this list of conditions and the following disclaimer.
|
||||
// * Redistributions in binary form must reproduce the above
|
||||
// copyright notice, this list of conditions and the following disclaimer
|
||||
// in the documentation and/or other materials provided with the
|
||||
// distribution.
|
||||
// * Neither the name of Google Inc. nor the names of its
|
||||
// contributors may be used to endorse or promote products derived from
|
||||
// this software without specific prior written permission.
|
||||
//
|
||||
// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
|
||||
// "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
|
||||
// LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
|
||||
// A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
|
||||
// OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
|
||||
// SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
|
||||
// LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
|
||||
// DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
|
||||
// THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
|
||||
// (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
||||
// OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
```
|
||||
|
||||
## ISC
|
||||
|
||||
Applied to: lucide-react-native (Copyright (c) 2026 Lucide Icons and Contributors)
|
||||
|
||||
```text
|
||||
ISC License
|
||||
|
||||
Copyright (c) 2026 Lucide Icons and Contributors
|
||||
|
||||
Permission to use, copy, modify, and/or distribute this software for any
|
||||
purpose with or without fee is hereby granted, provided that the above
|
||||
copyright notice and this permission notice appear in all copies.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
|
||||
WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
|
||||
MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
|
||||
ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
|
||||
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
|
||||
ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
|
||||
OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
---
|
||||
|
||||
The following Lucide icons are derived from the Feather project:
|
||||
|
||||
airplay, alert-circle, alert-octagon, alert-triangle, aperture, arrow-down-circle, arrow-down-left, arrow-down-right, arrow-down, arrow-left-circle, arrow-left, arrow-right-circle, arrow-right, arrow-up-circle, arrow-up-left, arrow-up-right, arrow-up, at-sign, calendar, cast, check, chevron-down, chevron-left, chevron-right, chevron-up, chevrons-down, chevrons-left, chevrons-right, chevrons-up, circle, clipboard, clock, code, columns, command, compass, corner-down-left, corner-down-right, corner-left-down, corner-left-up, corner-right-down, corner-right-up, corner-up-left, corner-up-right, crosshair, database, divide-circle, divide-square, dollar-sign, download, external-link, feather, frown, hash, headphones, help-circle, info, italic, key, layout, life-buoy, link-2, link, loader, lock, log-in, log-out, maximize, meh, minimize, minimize-2, minus-circle, minus-square, minus, monitor, moon, more-horizontal, more-vertical, move, music, navigation-2, navigation, octagon, pause-circle, percent, plus-circle, plus-square, plus, power, radio, rss, search, server, share, shopping-bag, sidebar, smartphone, smile, square, table-2, tablet, target, terminal, trash-2, trash, triangle, tv, type, upload, x-circle, x-octagon, x-square, x, zoom-in, zoom-out
|
||||
|
||||
The MIT License (MIT) (for the icons listed above)
|
||||
|
||||
Copyright (c) 2013-present Cole Bemis
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
```
|
||||
|
||||
## MIT
|
||||
|
||||
Applied to: @react-native-async-storage/async-storage (Copyright (c) 2015-present, Facebook, Inc.), @react-native-community/slider (react-native-community), @shopify/react-native-skia (Copyright 2021-present, Shopify Inc.), expo (Copyright (c) 2015-present 650 Industries, Inc. (aka Expo)), expo-asset (Copyright (c) 2015-present 650 Industries, Inc. (aka Expo)), expo-dev-client (Copyright (c) 2015-present 650 Industries, Inc. (aka Expo)), expo-document-picker (Copyright (c) 2015-present 650 Industries, Inc. (aka Expo)), expo-file-system (Copyright (c) 2015-present 650 Industries, Inc. (aka Expo)), expo-font (Copyright (c) 2015-present 650 Industries, Inc. (aka Expo)), expo-haptics (Copyright (c) 2015-present 650 Industries, Inc. (aka Expo)), expo-iap (Copyright (c) 2025 hyochan), expo-keep-awake (Copyright (c) 2015-present 650 Industries, Inc. (aka Expo)), expo-location (Copyright (c) 2015-present 650 Industries, Inc. (aka Expo)), expo-media-library (Copyright (c) 2015-present 650 Industries, Inc. (aka Expo)), expo-sharing (Copyright (c) 2015-present 650 Industries, Inc. (aka Expo)), expo-status-bar (Copyright (c) 2015-present 650 Industries, Inc. (aka Expo)), nativewind (Copyright (c) 2023 Mark Lawlor), react (Copyright (c) Meta Platforms, Inc. and affiliates.), react-dom (Copyright (c) Meta Platforms, Inc. and affiliates.), react-native (Copyright (c) Meta Platforms, Inc. and affiliates.), react-native-nitro-image (Marc Rousavy <me@mrousavy.com> (https://github.com/mrousavy)), react-native-nitro-modules (Marc Rousavy <me@mrousavy.com> (https://github.com/mrousavy)), react-native-reanimated (Copyright (c) 2016 Software Mansion <swmansion.com>), react-native-safe-area-context (Copyright (c) 2019 Th3rd Wave), react-native-vision-camera (Marc Rousavy <me@mrousavy.com> (https://github.com/mrousavy)), react-native-vision-camera-skia (Marc Rousavy <me@mrousavy.com> (https://github.com/mrousavy)), react-native-vision-camera-worklets (Marc Rousavy <me@mrousavy.com> (https://github.com/mrousavy)), react-native-web (Copyright (c) Nicolas Gallagher.)
|
||||
|
||||
```text
|
||||
MIT License
|
||||
|
||||
Copyright (c) Meta Platforms, Inc. and affiliates.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
```
|
||||
|
||||
## OFL-1.1
|
||||
|
||||
Applied to: Cousine (Copyright 2026 The Cousine Project Authors (github.com/googlefonts/cousine)), Noto Emoji (GPS subset) (Copyright 2022 The Noto Project Authors (github.com/notofonts/noto-emoji))
|
||||
|
||||
```text
|
||||
Copyright (c) <dates>, <Copyright Holder> (<URL|email>),
|
||||
with Reserved Font Name <Reserved Font Name>.
|
||||
Copyright (c) <dates>, <additional Copyright Holder> (<URL|email>),
|
||||
with Reserved Font Name <additional Reserved Font Name>.
|
||||
Copyright (c) <dates>, <additional Copyright Holder> (<URL|email>).
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
https://openfontlicense.org
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
```
|
||||
@@ -92,12 +92,29 @@ android {
|
||||
applicationId 'com.locphamtran.recipescamera'
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 1
|
||||
versionName "1.0.0"
|
||||
versionCode 9
|
||||
versionName "1.2.7"
|
||||
|
||||
buildConfigField "String", "REACT_NATIVE_RELEASE_LEVEL", "\"${findProperty('reactNativeReleaseLevel') ?: 'stable'}\""
|
||||
|
||||
ndk {
|
||||
abiFilters "armeabi-v7a", "arm64-v8a"
|
||||
}
|
||||
}
|
||||
signingConfigs {
|
||||
release {
|
||||
// Credentials live in android/keystore.properties (gitignored), next to
|
||||
// app/release.keystore. Lose either and no future build can install over
|
||||
// this one - back both up somewhere safe.
|
||||
def props = new Properties()
|
||||
def propsFile = rootProject.file('keystore.properties')
|
||||
if (!propsFile.exists()) throw new GradleException('android/keystore.properties missing')
|
||||
propsFile.withInputStream { props.load(it) }
|
||||
storeFile file('release.keystore')
|
||||
storePassword props.getProperty('storePassword')
|
||||
keyAlias props.getProperty('keyAlias')
|
||||
keyPassword props.getProperty('keyPassword')
|
||||
}
|
||||
debug {
|
||||
storeFile file('debug.keystore')
|
||||
storePassword 'android'
|
||||
@@ -105,6 +122,21 @@ android {
|
||||
keyPassword 'android'
|
||||
}
|
||||
}
|
||||
// Two products, two sales channels: lite = free apk handed out for feedback,
|
||||
// pro = paid apk. Same JS; the tier reaches JS via src/provariant.ts, which
|
||||
// tools/set-variant.mjs rewrites before each build.
|
||||
flavorDimensions "tier"
|
||||
productFlavors {
|
||||
lite {
|
||||
dimension "tier"
|
||||
}
|
||||
pro {
|
||||
dimension "tier"
|
||||
applicationIdSuffix ".pro"
|
||||
versionNameSuffix "-pro"
|
||||
resValue "string", "app_name", "RecipesCam Pro"
|
||||
}
|
||||
}
|
||||
buildTypes {
|
||||
debug {
|
||||
signingConfig signingConfigs.debug
|
||||
@@ -112,11 +144,14 @@ android {
|
||||
release {
|
||||
// Caution! In production, you need to generate your own keystore file.
|
||||
// see https://reactnative.dev/docs/signed-apk-android.
|
||||
signingConfig signingConfigs.debug
|
||||
signingConfig signingConfigs.release
|
||||
def enableShrinkResources = findProperty('android.enableShrinkResourcesInReleaseBuilds') ?: 'false'
|
||||
shrinkResources enableShrinkResources.toBoolean()
|
||||
minifyEnabled enableMinifyInReleaseBuilds
|
||||
proguardFiles getDefaultProguardFile("proguard-android.txt"), "proguard-rules.pro"
|
||||
// ponytail: proguard-android.txt carries -dontoptimize, which is what
|
||||
// Play flags as unoptimized code. The -optimize file drops it; R8's
|
||||
// optimizer + keep rules from the RN/Expo AARs carry the rest.
|
||||
proguardFiles getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro"
|
||||
def enablePngCrunchInRelease = findProperty('android.enablePngCrunchInReleaseBuilds') ?: 'true'
|
||||
crunchPngs enablePngCrunchInRelease.toBoolean()
|
||||
}
|
||||
@@ -129,6 +164,12 @@ android {
|
||||
}
|
||||
androidResources {
|
||||
ignoreAssetsPattern '!.svn:!.git:!.ds_store:!*.scc:!CVS:!thumbs.db:!picasa.ini:!*~'
|
||||
// assets/index.android.bundle is 4.4 MB of Hermes bytecode. Packed
|
||||
// deflated (the default) the app pays a full inflate of it on the
|
||||
// startup path, before the first line of JS runs; stored, it is mapped
|
||||
// straight out of the APK. Costs ~3 MB of download Play re-compresses
|
||||
// anyway.
|
||||
noCompress += ["bundle", "hbc"]
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -11,4 +11,9 @@
|
||||
-keep class com.swmansion.reanimated.** { *; }
|
||||
-keep class com.facebook.react.turbomodule.** { *; }
|
||||
|
||||
# androidx.camera
|
||||
-keep class androidx.camera.** { *; }
|
||||
-keep interface androidx.camera.** { *; }
|
||||
-dontwarn androidx.camera.**
|
||||
|
||||
# Add any project specific keep options here:
|
||||
|
||||
@@ -1,15 +1,13 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android" xmlns:tools="http://schemas.android.com/tools">
|
||||
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION"/>
|
||||
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"/>
|
||||
<uses-permission android:name="android.permission.ACCESS_MEDIA_LOCATION"/>
|
||||
<uses-permission android:name="android.permission.CAMERA"/>
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32" tools:replace="android:maxSdkVersion"/>
|
||||
<uses-permission android:name="android.permission.READ_MEDIA_AUDIO"/>
|
||||
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES"/>
|
||||
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO"/>
|
||||
<uses-permission android:name="android.permission.READ_MEDIA_VISUAL_USER_SELECTED"/>
|
||||
<uses-permission android:name="android.permission.RECORD_AUDIO"/>
|
||||
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW"/>
|
||||
<uses-permission android:name="android.permission.RECORD_AUDIO" tools:node="remove"/>
|
||||
<uses-permission android:name="android.permission.VIBRATE"/>
|
||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:maxSdkVersion="32" tools:replace="android:maxSdkVersion"/>
|
||||
<queries>
|
||||
@@ -19,7 +17,7 @@
|
||||
<data android:scheme="https"/>
|
||||
</intent>
|
||||
</queries>
|
||||
<application android:name=".MainApplication" android:label="@string/app_name" android:icon="@mipmap/ic_launcher" android:roundIcon="@mipmap/ic_launcher_round" android:allowBackup="true" android:theme="@style/AppTheme" android:supportsRtl="true" android:enableOnBackInvokedCallback="false" android:requestLegacyExternalStorage="true">
|
||||
<application android:name=".MainApplication" android:label="@string/app_name" android:icon="@mipmap/ic_launcher" android:roundIcon="@mipmap/ic_launcher_round" android:allowBackup="true" android:theme="@style/AppTheme" android:supportsRtl="true" android:enableOnBackInvokedCallback="false">
|
||||
<meta-data android:name="expo.modules.updates.ENABLED" android:value="false"/>
|
||||
<meta-data android:name="expo.modules.updates.ENABLE_BSDIFF_PATCH_SUPPORT" android:value="true"/>
|
||||
<meta-data android:name="expo.modules.updates.EXPO_UPDATES_CHECK_ON_LAUNCH" android:value="ALWAYS"/>
|
||||
@@ -36,5 +34,19 @@
|
||||
<data android:scheme="exp+recipescamera"/>
|
||||
</intent-filter>
|
||||
</activity>
|
||||
<!-- Share target. Without this filter the app never appears in the
|
||||
system share sheet at all; image/* only, the editor takes photos.
|
||||
A separate no-display relay, not MainActivity: a host app that starts
|
||||
the target without FLAG_ACTIVITY_NEW_TASK would otherwise drop the
|
||||
editor into its own task and the share would look like part of it.
|
||||
ShareActivity relaunches MainActivity in our own task instead. -->
|
||||
<activity android:name=".ShareActivity" android:theme="@android:style/Theme.NoDisplay" android:launchMode="standard" android:excludeFromRecents="true" android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.SEND"/>
|
||||
<action android:name="android.intent.action.SEND_MULTIPLE"/>
|
||||
<category android:name="android.intent.category.DEFAULT"/>
|
||||
<data android:mimeType="image/*"/>
|
||||
</intent-filter>
|
||||
</activity>
|
||||
</application>
|
||||
</manifest>
|
||||
@@ -1,13 +1,17 @@
|
||||
package com.locphamtran.recipescamera
|
||||
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.view.KeyEvent
|
||||
|
||||
import com.facebook.react.ReactActivity
|
||||
import com.facebook.react.ReactActivityDelegate
|
||||
import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint.fabricEnabled
|
||||
import com.facebook.react.defaults.DefaultReactActivityDelegate
|
||||
|
||||
import com.locphamtran.recipescamera.volume.VolumeShutterModule
|
||||
|
||||
import expo.modules.ReactActivityDelegateWrapper
|
||||
|
||||
class MainActivity : ReactActivity() {
|
||||
@@ -19,6 +23,28 @@ class MainActivity : ReactActivity() {
|
||||
super.onCreate(null)
|
||||
}
|
||||
|
||||
/**
|
||||
* A share delivered to the running app lands here, and React Native never hands
|
||||
* the new intent to the activity (nothing in ReactActivity/ReactDelegate calls
|
||||
* setIntent) — so getIntent() keeps returning the launch intent and the share
|
||||
* intake would read a photo the user shared minutes ago, or none at all.
|
||||
*/
|
||||
override fun onNewIntent(intent: Intent) {
|
||||
setIntent(intent)
|
||||
super.onNewIntent(intent)
|
||||
}
|
||||
|
||||
/**
|
||||
* Volume keys double as a shutter release while the camera screen is up.
|
||||
* This is the only place that runs before the window turns them into a
|
||||
* stream-volume change, so the module gets first refusal and the keys it
|
||||
* does not claim (everything, outside the camera) fall through to the
|
||||
* default handling unchanged.
|
||||
*/
|
||||
override fun dispatchKeyEvent(event: KeyEvent): Boolean {
|
||||
return VolumeShutterModule.onKeyEvent(event) || super.dispatchKeyEvent(event)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the name of the main component registered from JavaScript. This is used to schedule
|
||||
* rendering of the component.
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
package com.locphamtran.recipescamera
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
|
||||
/**
|
||||
* Share sheet entry point for SEND / SEND_MULTIPLE.
|
||||
*
|
||||
* A host app that starts us without FLAG_ACTIVITY_NEW_TASK drops the started
|
||||
* activity into its own task, so the editor used to show up as a screen of that
|
||||
* app (it lived in that app's stack, back returned there). MainActivity cannot
|
||||
* fix that itself: bailing out in onCreate would either boot React twice or skip
|
||||
* super.onCreate and crash onDestroy.
|
||||
*
|
||||
* So the share filters live here. This activity never draws (NoDisplay theme):
|
||||
* it hands the same intent to MainActivity with NEW_TASK — singleTask then routes
|
||||
* it to our own task, or re-delivers it to the running instance — and finishes.
|
||||
* The editor is therefore always a task of its own.
|
||||
*/
|
||||
class ShareActivity : Activity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
startActivity(
|
||||
Intent(intent)
|
||||
.setClass(this, MainActivity::class.java)
|
||||
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
|
||||
finish()
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 46 KiB |
|
After Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 65 KiB |
|
After Width: | Height: | Size: 90 KiB |
@@ -0,0 +1,6 @@
|
||||
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:drawable="@color/splashscreen_background" />
|
||||
<item
|
||||
android:gravity="center"
|
||||
android:drawable="@drawable/splashscreen_logo_img" />
|
||||
</layer-list>
|
||||
|
Before Width: | Height: | Size: 5.7 KiB After Width: | Height: | Size: 2.8 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 7.6 KiB |
|
Before Width: | Height: | Size: 2.1 KiB After Width: | Height: | Size: 3.2 KiB |
|
Before Width: | Height: | Size: 6.4 KiB After Width: | Height: | Size: 2.8 KiB |
|
Before Width: | Height: | Size: 3.0 KiB After Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 5.5 KiB After Width: | Height: | Size: 4.5 KiB |
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 3.3 KiB After Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 9.0 KiB After Width: | Height: | Size: 3.8 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 2.6 KiB After Width: | Height: | Size: 4.5 KiB |
|
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 3.8 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 6.3 KiB |
|
Before Width: | Height: | Size: 39 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 4.9 KiB After Width: | Height: | Size: 7.2 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 6.3 KiB |
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 9.2 KiB |
|
Before Width: | Height: | Size: 66 KiB After Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 5.2 KiB After Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 9.2 KiB |
@@ -0,0 +1,9 @@
|
||||
<resources xmlns:tools="http://schemas.android.com/tools">
|
||||
<!-- Android 12+ system splash: branded logo (app_logo.png) centered on white. -->
|
||||
<style name="Theme.App.SplashScreen" parent="AppTheme">
|
||||
<item name="android:windowBackground">@drawable/splashscreen_logo</item>
|
||||
<item name="android:windowSplashScreenBackground">@color/splashscreen_background</item>
|
||||
<item name="android:windowSplashScreenAnimatedIcon">@drawable/splashscreen_logo_img</item>
|
||||
<item name="android:windowSplashScreenIconBackgroundColor">@color/splashscreen_background</item>
|
||||
</style>
|
||||
</resources>
|
||||
@@ -1,3 +1,3 @@
|
||||
<resources>
|
||||
<string name="app_name">recipescamera</string>
|
||||
<string name="app_name">RecipesCam</string>
|
||||
</resources>
|
||||
@@ -22,3 +22,8 @@ allprojects {
|
||||
|
||||
apply plugin: "expo-root-project"
|
||||
apply plugin: "com.facebook.react.rootproject"
|
||||
|
||||
// v5: vision-camera module builds own C++ (NativeBufferHelper.cpp) with
|
||||
// `minSdkVersion getExtOrIntegerDefault("minSdkVersion")` = rootProject.ext.minSdkVersion.
|
||||
// C++ #if __ANDROID_API__ >= 26 else throws "HardwareBuffers require minSdk 26 or higher!" on every frame.
|
||||
rootProject.ext.minSdkVersion = 26
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
# Specifies the JVM arguments used for the daemon process.
|
||||
# The setting is particularly useful for tweaking memory settings.
|
||||
# Default value: -Xmx512m -XX:MaxMetaspaceSize=256m
|
||||
org.gradle.jvmargs=-Xmx2048m -XX:MaxMetaspaceSize=512m
|
||||
org.gradle.jvmargs=-Xmx4096m -XX:MaxMetaspaceSize=1024m -XX:+HeapDumpOnOutOfMemoryError
|
||||
|
||||
# When configured, Gradle will run in incubating parallel mode.
|
||||
# This option should only be used with decoupled projects. More details, visit
|
||||
@@ -21,6 +21,7 @@ org.gradle.parallel=true
|
||||
# Android operating system, and which are packaged with your app's APK
|
||||
# https://developer.android.com/topic/libraries/support-library/androidx-rn
|
||||
android.useAndroidX=true
|
||||
android.enableJetifier=true
|
||||
|
||||
# Enable AAPT2 PNG crunching
|
||||
android.enablePngCrunchInReleaseBuilds=true
|
||||
@@ -28,7 +29,7 @@ android.enablePngCrunchInReleaseBuilds=true
|
||||
# Use this property to specify which architecture you want to build.
|
||||
# You can also override it from the CLI using
|
||||
# ./gradlew <task> -PreactNativeArchitectures=x86_64
|
||||
reactNativeArchitectures=armeabi-v7a,arm64-v8a,x86,x86_64
|
||||
reactNativeArchitectures=armeabi-v7a,arm64-v8a
|
||||
|
||||
# Use this property to enable support to the new architecture.
|
||||
# This will allow you to use TurboModules and the Fabric render in
|
||||
@@ -60,4 +61,10 @@ EX_DEV_CLIENT_NETWORK_INSPECTOR=true
|
||||
# Use legacy packaging to compress native libraries in the resulting APK.
|
||||
expo.useLegacyPackaging=false
|
||||
|
||||
expo.inlineModules.watchedDirectories=[]
|
||||
expo.inlineModules.watchedDirectories=[]
|
||||
|
||||
# Shrink the shipped APK: R8 strips unused Java/Kotlin, shrinkResources drops
|
||||
# unreferenced resources, bundle compression gzips the embedded JS bundle.
|
||||
android.enableMinifyInReleaseBuilds=true
|
||||
android.enableShrinkResourcesInReleaseBuilds=true
|
||||
android.enableBundleCompression=true
|
||||
|
||||
@@ -37,3 +37,11 @@ expoAutolinking.useExpoVersionCatalog()
|
||||
|
||||
include ':app'
|
||||
includeBuild(expoAutolinking.reactNativeGradlePlugin)
|
||||
|
||||
// v5: vision-camera(-worklets) native needs minSdk 26 (AHardwareBuffer). Parallel project
|
||||
// configuration races root build.gradle's assignment, so pin the ext here before any project config.
|
||||
gradle.beforeProject { project ->
|
||||
if (project == project.rootProject) {
|
||||
project.ext.minSdkVersion = 26
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,70 +1,66 @@
|
||||
{
|
||||
"expo": {
|
||||
"name": "recipescamera",
|
||||
"slug": "recipescamera",
|
||||
"version": "1.0.0",
|
||||
"orientation": "portrait",
|
||||
"icon": "./assets/icon.png",
|
||||
"userInterfaceStyle": "light",
|
||||
"ios": {
|
||||
"supportsTablet": true
|
||||
},
|
||||
"android": {
|
||||
"adaptiveIcon": {
|
||||
"backgroundColor": "#E6F4FE",
|
||||
"foregroundImage": "./assets/android-icon-foreground.png",
|
||||
"backgroundImage": "./assets/android-icon-background.png",
|
||||
"monochromeImage": "./assets/android-icon-monochrome.png"
|
||||
},
|
||||
"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"
|
||||
],
|
||||
"package": "com.locphamtran.recipescamera"
|
||||
},
|
||||
"web": {
|
||||
"favicon": "./assets/favicon.png"
|
||||
},
|
||||
"plugins": [
|
||||
"expo-asset",
|
||||
[
|
||||
"expo-camera",
|
||||
{
|
||||
"cameraPermission": "Allow CamRecipe Pro to access your camera to take photos."
|
||||
}
|
||||
],
|
||||
[
|
||||
"expo-location",
|
||||
{
|
||||
"locationAlwaysAndWhenInUsePermission": "Allow CamRecipe Pro to geotag your photos with location watermark."
|
||||
}
|
||||
],
|
||||
[
|
||||
"expo-media-library",
|
||||
{
|
||||
"photosPermission": "Allow CamRecipe Pro to save processed photos to your library."
|
||||
}
|
||||
],
|
||||
[
|
||||
"expo-image-picker",
|
||||
{
|
||||
"photosPermission": "Allow CamRecipe Pro to select photos from your library to apply presets."
|
||||
}
|
||||
]
|
||||
],
|
||||
"extra": {
|
||||
"eas": {
|
||||
"projectId": "31b3c7c4-c00c-447d-aa77-a087f78480ef"
|
||||
}
|
||||
"expo": {
|
||||
"name": "RecipesCam",
|
||||
"slug": "recipescamera",
|
||||
"version": "1.2.7",
|
||||
"orientation": "portrait",
|
||||
"icon": "./RecipesCamIcon.png",
|
||||
"userInterfaceStyle": "light",
|
||||
"ios": {
|
||||
"supportsTablet": true,
|
||||
"infoPlist": {
|
||||
"NSCameraUsageDescription": "Allow RecipesCam to access your camera."
|
||||
}
|
||||
},
|
||||
"android": {
|
||||
"adaptiveIcon": {
|
||||
"backgroundColor": "#E6F4FE",
|
||||
"foregroundImage": "./RecipesCamIcon.png",
|
||||
"backgroundImage": "./assets/android-icon-background.png",
|
||||
"monochromeImage": "./assets/android-icon-monochrome.png"
|
||||
},
|
||||
"predictiveBackGestureEnabled": false,
|
||||
"permissions": [
|
||||
"android.permission.CAMERA",
|
||||
"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"
|
||||
],
|
||||
"package": "com.locphamtran.recipescamera"
|
||||
},
|
||||
"web": {
|
||||
"favicon": "./assets/favicon.png"
|
||||
},
|
||||
"plugins": [
|
||||
"expo-asset",
|
||||
[
|
||||
"expo-location",
|
||||
{
|
||||
"locationAlwaysAndWhenInUsePermission": "Allow RecipesCam to geotag your photos with location watermark."
|
||||
}
|
||||
],
|
||||
[
|
||||
"expo-media-library",
|
||||
{
|
||||
"photosPermission": "Allow RecipesCam to save processed photos to your library.",
|
||||
"isAccessMediaLocationEnabled": true
|
||||
}
|
||||
],
|
||||
[
|
||||
"expo-camera",
|
||||
{
|
||||
"cameraPermission": "Allow RecipesCam to scan a recipe QR code."
|
||||
}
|
||||
],
|
||||
"expo-sharing"
|
||||
],
|
||||
"extra": {
|
||||
"eas": {
|
||||
"projectId": "31b3c7c4-c00c-447d-aa77-a087f78480ef"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
After Width: | Height: | Size: 428 KiB |
@@ -1,3 +1,28 @@
|
||||
// Lucide ships one module per icon, but the barrel import drags all ~1600 in.
|
||||
// Rewrite each named import into its own icon module so Metro only walks what we use.
|
||||
const lucidePerIcon = () => ({
|
||||
visitor: {
|
||||
ImportDeclaration(path) {
|
||||
if (path.node.source.value !== "lucide-react-native") return;
|
||||
const t = require("@babel/types");
|
||||
const kebab = (n) =>
|
||||
n
|
||||
.replace(/([a-z0-9])([A-Z])/g, "$1-$2")
|
||||
.replace(/([A-Za-z])(\d)/g, "$1-$2")
|
||||
.toLowerCase();
|
||||
path.replaceWithMultiple(
|
||||
path.node.specifiers.map((s) =>
|
||||
s.type === "ImportSpecifier"
|
||||
? t.importDeclaration(
|
||||
[t.importDefaultSpecifier(s.local)],
|
||||
t.stringLiteral(`lucide-react-native/icons/${kebab(s.imported.name)}`)
|
||||
)
|
||||
: t.importDeclaration([s], t.stringLiteral("lucide-react-native"))
|
||||
)
|
||||
);
|
||||
},
|
||||
},
|
||||
});
|
||||
module.exports = function (api) {
|
||||
api.cache(true);
|
||||
return {
|
||||
@@ -7,6 +32,7 @@ module.exports = function (api) {
|
||||
],
|
||||
plugins: [
|
||||
"react-native-reanimated/plugin",
|
||||
lucidePerIcon
|
||||
],
|
||||
};
|
||||
};
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>RecipesCam — Privacy Policy</title>
|
||||
<style>
|
||||
:root { color-scheme: light dark; }
|
||||
body { max-width: 46rem; margin: 0 auto; padding: 2rem 1.25rem 4rem;
|
||||
font: 16px/1.65 -apple-system, Segoe UI, Roboto, Helvetica, Arial, sans-serif; }
|
||||
h1 { font-size: 1.6rem; margin-bottom: .25rem; }
|
||||
h2 { font-size: 1.1rem; margin-top: 2rem; }
|
||||
.meta { color: #666; font-size: .9rem; }
|
||||
code { background: rgba(127,127,127,.15); padding: .1em .3em; border-radius: 4px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>RecipesCam — Privacy Policy</h1>
|
||||
<p class="meta">Applies to the Android apps <strong>RecipesCam</strong> (com.locphamtran.recipescamera, free/LITE)
|
||||
and <strong>RecipesCam Pro</strong> (com.locphamtran.recipescamera.pro, paid/PRO).<br>
|
||||
Effective date: 14 September 2026 · Last updated: 14 September 2026</p>
|
||||
|
||||
<p>RecipesCam is a camera app: it applies film-style recipes to photos you take or pick, and saves the
|
||||
result to your device. It is built and published by an individual developer ("we"). This policy explains
|
||||
what the app accesses and what happens to that data.</p>
|
||||
|
||||
<h2>Summary</h2>
|
||||
<ul>
|
||||
<li>The app has <strong>no user accounts</strong> and <strong>no server of ours</strong>.</li>
|
||||
<li><strong>No photo, location, or usage data is transmitted to us or to any third party.</strong> There is no
|
||||
analytics, advertising, crash-reporting, or tracking SDK in the app.</li>
|
||||
<li>Everything the app does — capture, processing, watermarking, saving — happens on your device.</li>
|
||||
</ul>
|
||||
|
||||
<h2>Data the app accesses, and why</h2>
|
||||
<table>
|
||||
<tr><th align="left">Access</th><th align="left">Permission</th><th align="left">Purpose</th></tr>
|
||||
<tr>
|
||||
<td>Camera</td><td><code>CAMERA</code></td>
|
||||
<td>To take photos and show the live preview. Photos stay on your device.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Photos and videos in your library</td>
|
||||
<td><code>READ_MEDIA_IMAGES</code>, <code>READ_MEDIA_VISUAL_USER_SELECTED</code>,
|
||||
<code>READ_EXTERNAL_STORAGE</code> (Android 12 and older)</td>
|
||||
<td>To let you pick an existing photo to edit, and to show your library inside the app's picker.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Save processed photos</td>
|
||||
<td><code>WRITE_EXTERNAL_STORAGE</code> (Android 9 and older) and the system media-library APIs</td>
|
||||
<td>To write the processed photo and its metadata back to your gallery.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Photo location metadata (EXIF GPS)</td><td><code>ACCESS_MEDIA_LOCATION</code></td>
|
||||
<td>To let a photo you imported keep its location when you export it again. Read on device only.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Your location</td>
|
||||
<td><code>ACCESS_COARSE_LOCATION</code>, <code>ACCESS_FINE_LOCATION</code></td>
|
||||
<td>Used only when you switch on the optional location watermark, to stamp the place name onto the
|
||||
photo. Location is never collected in the background, never stored beyond the photo you asked to
|
||||
geotag, and never sent to us.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Vibration</td><td><code>VIBRATE</code></td><td>Haptic feedback on shutter and controls.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Network</td><td><code>INTERNET</code></td>
|
||||
<td>Present for the app's runtime, which is built on React Native. The app itself makes no network
|
||||
requests and contacts no server we operate.</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<p>The app does not request access to your microphone, contacts, calendar, health data, SMS, call logs,
|
||||
files outside your photo library, or your physical activity, and it does not use the
|
||||
"display over other apps" permission.</p>
|
||||
|
||||
<h2>Storage on your device</h2>
|
||||
<p>Your recipes, presets, favourites and app settings are stored locally in the app's private storage on
|
||||
your device. Removing the app removes them. Photos you take or export live in your own gallery, under
|
||||
your control; the app keeps no copy of its own.</p>
|
||||
|
||||
<h2>Sharing</h2>
|
||||
<p>When you tap Share, the app hands the chosen photo to the app you pick through Android's standard share
|
||||
sheet. What that app then does with the photo is governed by its own privacy policy. We receive nothing.</p>
|
||||
|
||||
<h2>Third-party software</h2>
|
||||
<p>The app is built with open-source libraries (React Native, Expo, Skia, VisionCamera, and others). They
|
||||
run locally inside the app and do not collect data on our behalf. Their licences and copyright notices are
|
||||
listed in the app under <em>Settings → About → Open-source licences</em> and in the
|
||||
<code>THIRD_PARTY_NOTICES.md</code> file shipped with the source.</p>
|
||||
|
||||
<h2>Children</h2>
|
||||
<p>The app is not directed at children under 13 and does not knowingly collect personal information from
|
||||
anyone, including children.</p>
|
||||
|
||||
<h2>Your rights and choices</h2>
|
||||
<p>Because no personal data leaves your device, there is nothing for us to delete or export on your behalf.
|
||||
You can revoke any permission at any time in Android's <em>Settings → Apps → RecipesCam → Permissions</em>;
|
||||
the app will keep working minus the feature that needs it. Uninstalling the app deletes all data it stored
|
||||
locally.</p>
|
||||
|
||||
<h2>Changes to this policy</h2>
|
||||
<p>If this policy changes, the effective date above is updated and the new text is published at the same
|
||||
URL before it takes effect.</p>
|
||||
|
||||
<h2>Contact</h2>
|
||||
<p>Questions about this policy or the app: <a href="mailto:tranloclqd@gmail.com">tranloclqd@gmail.com</a>.</p>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,8 +1,21 @@
|
||||
import { registerRootComponent } from 'expo';
|
||||
import React from 'react';
|
||||
import { SafeAreaProvider } from 'react-native-safe-area-context';
|
||||
|
||||
import App from './App';
|
||||
|
||||
// registerRootComponent calls AppRegistry.registerComponent('main', () => App);
|
||||
// SafeAreaProvider: root overlays (TopBar / CameraControls) read insets so the
|
||||
// fullscreen viewfinder can run edge-to-edge under the system bars.
|
||||
// (index.ts is plain TS — no JSX here.)
|
||||
function Root() {
|
||||
return React.createElement(
|
||||
SafeAreaProvider,
|
||||
null,
|
||||
React.createElement(App)
|
||||
);
|
||||
}
|
||||
|
||||
// registerRootComponent calls AppRegistry.registerComponent('main', () => Root);
|
||||
// It also ensures that whether you load the app in Expo Go or in a native build,
|
||||
// the environment is set up appropriately
|
||||
registerRootComponent(App);
|
||||
registerRootComponent(Root);
|
||||
|
||||