Compare commits
148 Commits
fixbug-01
...
9f7d83276d
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 |
@@ -1,14 +0,0 @@
|
||||
> Why do I have a folder named ".expo" in my project?
|
||||
|
||||
The ".expo" folder is created when an Expo project is started using "expo start" command.
|
||||
|
||||
> What do the files contain?
|
||||
|
||||
- "devices.json": contains information about devices that have recently opened this project. This is used to populate the "Development sessions" list in your development builds.
|
||||
- "settings.json": contains the server configuration that is used to serve the application manifest.
|
||||
- "dev/logs/": contains structured JSONL event logs from CLI commands (e.g. start.log, export.log). These are truncated on each run.
|
||||
|
||||
> Should I commit the ".expo" folder?
|
||||
|
||||
No, you should not share the ".expo" folder. It does not contain any information that is relevant for other developers working on the project, it is specific to your machine.
|
||||
Upon project creation, the ".expo" folder is already added to your ".gitignore" file.
|
||||
@@ -1,412 +0,0 @@
|
||||
{"_e":"root:init","_t":1784254504055,"format":"v0-jsonl","version":"57.0.8"}
|
||||
{"_e":"env:mode","_t":1784254504056,"nodeEnv":"development","babelEnv":"development","mode":"development"}
|
||||
{"_e":"env:load","_t":1784254504058,"mode":"development","files":[],"env":{}}
|
||||
{"_e":"devserver:start","_t":1784254508949,"mode":"development","web":true,"baseUrl":"","asyncRoutes":false,"routerRoot":"../../../app","serverComponents":false,"serverActions":false,"serverRendering":false,"apiRoutes":false,"exporting":false}
|
||||
{"_e":"metro:config","_t":1784254509422,"serverRoot":"../../..","projectRoot":"../../..","exporting":false,"flags":{"autolinkingModuleResolution":false,"serverActions":false,"serverComponents":false,"reactCompiler":false,"optimizeGraph":false,"treeshaking":false,"logbox":false}}
|
||||
{"_e":"expo:dev-tools-plugin:load","_t":1784254509956,"plugins":[]}
|
||||
{"_e":"metro:instantiate","_t":1784254516842,"atlas":false,"workers":3,"host":"::","port":8086}
|
||||
{"_e":"metro:bundling:started","_t":1784254564715,"id":"1","platform":"android","environment":null,"entry":"index.ts"}
|
||||
{"_e":"metro:bundling:progress","_t":1784254564715,"id":"1","progress":0,"total":1,"current":0}
|
||||
{"_e":"metro:bundling:progress","_t":1784254564729,"id":"1","progress":0,"total":1,"current":0}
|
||||
{"_e":"metro:bundling:progress","_t":1784254565214,"id":"1","progress":0.010000000000000002,"total":1,"current":1}
|
||||
{"_e":"metro:bundling:progress","_t":1784254565318,"id":"1","progress":0.09,"total":9,"current":3}
|
||||
{"_e":"metro:bundling:progress","_t":1784254565418,"id":"1","progress":0.09,"total":41,"current":7}
|
||||
{"_e":"metro:bundling:progress","_t":1784254565518,"id":"1","progress":0.09,"total":46,"current":11}
|
||||
{"_e":"metro:bundling:progress","_t":1784254565577,"id":"1","progress":0.09,"total":46,"current":12}
|
||||
{"_e":"metro:bundling:progress","_t":1784254565690,"id":"1","progress":0.09,"total":147,"current":14}
|
||||
{"_e":"metro:bundling:progress","_t":1784254565794,"id":"1","progress":0.09,"total":156,"current":24}
|
||||
{"_e":"metro:bundling:progress","_t":1784254565894,"id":"1","progress":0.09,"total":157,"current":31}
|
||||
{"_e":"metro:bundling:progress","_t":1784254565998,"id":"1","progress":0.09,"total":185,"current":45}
|
||||
{"_e":"metro:bundling:progress","_t":1784254566099,"id":"1","progress":0.09,"total":197,"current":52}
|
||||
{"_e":"metro:bundling:progress","_t":1784254566211,"id":"1","progress":0.09,"total":210,"current":59}
|
||||
{"_e":"metro:bundling:progress","_t":1784254566311,"id":"1","progress":0.09,"total":223,"current":66}
|
||||
{"_e":"metro:bundling:progress","_t":1784254566419,"id":"1","progress":0.09782840730462457,"total":227,"current":71}
|
||||
{"_e":"metro:bundling:progress","_t":1784254566519,"id":"1","progress":0.11201999167013746,"total":245,"current":82}
|
||||
{"_e":"metro:bundling:progress","_t":1784254566623,"id":"1","progress":0.15123456790123457,"total":270,"current":105}
|
||||
{"_e":"metro:bundling:progress","_t":1784254566723,"id":"1","progress":0.18367346938775508,"total":287,"current":123}
|
||||
{"_e":"metro:bundling:progress","_t":1784254566823,"id":"1","progress":0.2352733413538482,"total":301,"current":146}
|
||||
{"_e":"metro:bundling:progress","_t":1784254566923,"id":"1","progress":0.259462890625,"total":320,"current":163}
|
||||
{"_e":"metro:bundling:progress","_t":1784254567024,"id":"1","progress":0.27952464836940155,"total":331,"current":175}
|
||||
{"_e":"metro:bundling:progress","_t":1784254567124,"id":"1","progress":0.33271161520688924,"total":345,"current":199}
|
||||
{"_e":"metro:bundling:progress","_t":1784254567225,"id":"1","progress":0.35676406135865596,"total":370,"current":221}
|
||||
{"_e":"metro:bundling:progress","_t":1784254567326,"id":"1","progress":0.37024645369120834,"total":401,"current":244}
|
||||
{"_e":"metro:bundling:progress","_t":1784254567428,"id":"1","progress":0.37472705039741466,"total":428,"current":262}
|
||||
{"_e":"metro:bundling:progress","_t":1784254567529,"id":"1","progress":0.3978698224852071,"total":455,"current":287}
|
||||
{"_e":"metro:bundling:progress","_t":1784254567628,"id":"1","progress":0.41077662667090886,"total":479,"current":307}
|
||||
{"_e":"metro:bundling:progress","_t":1784254567730,"id":"1","progress":0.42145226326742974,"total":496,"current":322}
|
||||
{"_e":"metro:bundling:progress","_t":1784254567834,"id":"1","progress":0.42145226326742974,"total":536,"current":333}
|
||||
{"_e":"metro:bundling:progress","_t":1784254567938,"id":"1","progress":0.42145226326742974,"total":540,"current":339}
|
||||
{"_e":"metro:bundling:progress","_t":1784254568057,"id":"1","progress":0.42145226326742974,"total":543,"current":345}
|
||||
{"_e":"metro:bundling:progress","_t":1784254568158,"id":"1","progress":0.42145226326742974,"total":556,"current":354}
|
||||
{"_e":"metro:bundling:progress","_t":1784254568257,"id":"1","progress":0.42145226326742974,"total":556,"current":356}
|
||||
{"_e":"metro:bundling:progress","_t":1784254568357,"id":"1","progress":0.42472014414228576,"total":557,"current":363}
|
||||
{"_e":"metro:bundling:progress","_t":1784254568458,"id":"1","progress":0.42472014414228576,"total":571,"current":370}
|
||||
{"_e":"metro:bundling:progress","_t":1784254568559,"id":"1","progress":0.44212055590581395,"total":573,"current":381}
|
||||
{"_e":"metro:bundling:progress","_t":1784254568663,"id":"1","progress":0.46466158211707237,"total":578,"current":394}
|
||||
{"_e":"metro:bundling:progress","_t":1784254568764,"id":"1","progress":0.48831470460153875,"total":581,"current":406}
|
||||
{"_e":"metro:bundling:progress","_t":1784254568909,"id":"1","progress":0.500828402366864,"total":585,"current":414}
|
||||
{"_e":"metro:bundling:progress","_t":1784254569015,"id":"1","progress":0.500828402366864,"total":2334,"current":415}
|
||||
{"_e":"metro:bundling:progress","_t":1784254569115,"id":"1","progress":0.500828402366864,"total":2338,"current":435}
|
||||
{"_e":"metro:bundling:progress","_t":1784254569222,"id":"1","progress":0.500828402366864,"total":2343,"current":440}
|
||||
{"_e":"metro:bundling:progress","_t":1784254569326,"id":"1","progress":0.500828402366864,"total":2346,"current":459}
|
||||
{"_e":"metro:bundling:progress","_t":1784254569467,"id":"1","progress":0.500828402366864,"total":2347,"current":467}
|
||||
{"_e":"metro:bundling:progress","_t":1784254569567,"id":"1","progress":0.500828402366864,"total":2348,"current":472}
|
||||
{"_e":"metro:bundling:progress","_t":1784254569667,"id":"1","progress":0.500828402366864,"total":2367,"current":491}
|
||||
{"_e":"metro:bundling:progress","_t":1784254569771,"id":"1","progress":0.500828402366864,"total":2428,"current":523}
|
||||
{"_e":"metro:bundling:progress","_t":1784254569871,"id":"1","progress":0.500828402366864,"total":2454,"current":542}
|
||||
{"_e":"metro:bundling:progress","_t":1784254569971,"id":"1","progress":0.500828402366864,"total":2465,"current":563}
|
||||
{"_e":"metro:bundling:progress","_t":1784254570071,"id":"1","progress":0.500828402366864,"total":2477,"current":583}
|
||||
{"_e":"metro:bundling:progress","_t":1784254570173,"id":"1","progress":0.500828402366864,"total":2478,"current":625}
|
||||
{"_e":"metro:bundling:progress","_t":1784254570273,"id":"1","progress":0.500828402366864,"total":2478,"current":677}
|
||||
{"_e":"metro:bundling:progress","_t":1784254570374,"id":"1","progress":0.500828402366864,"total":2478,"current":727}
|
||||
{"_e":"metro:bundling:progress","_t":1784254570474,"id":"1","progress":0.500828402366864,"total":2478,"current":772}
|
||||
{"_e":"metro:bundling:progress","_t":1784254570574,"id":"1","progress":0.500828402366864,"total":2478,"current":818}
|
||||
{"_e":"metro:bundling:progress","_t":1784254570674,"id":"1","progress":0.500828402366864,"total":2478,"current":863}
|
||||
{"_e":"metro:bundling:progress","_t":1784254570774,"id":"1","progress":0.500828402366864,"total":2478,"current":912}
|
||||
{"_e":"metro:bundling:progress","_t":1784254570874,"id":"1","progress":0.500828402366864,"total":2478,"current":965}
|
||||
{"_e":"metro:bundling:progress","_t":1784254570974,"id":"1","progress":0.500828402366864,"total":2478,"current":1008}
|
||||
{"_e":"metro:bundling:progress","_t":1784254571076,"id":"1","progress":0.500828402366864,"total":2478,"current":1056}
|
||||
{"_e":"metro:bundling:progress","_t":1784254571176,"id":"1","progress":0.500828402366864,"total":2478,"current":1105}
|
||||
{"_e":"metro:bundling:progress","_t":1784254571277,"id":"1","progress":0.500828402366864,"total":2478,"current":1160}
|
||||
{"_e":"metro:bundling:progress","_t":1784254571377,"id":"1","progress":0.500828402366864,"total":2478,"current":1209}
|
||||
{"_e":"metro:bundling:progress","_t":1784254571478,"id":"1","progress":0.500828402366864,"total":2478,"current":1264}
|
||||
{"_e":"metro:bundling:progress","_t":1784254571578,"id":"1","progress":0.500828402366864,"total":2478,"current":1311}
|
||||
{"_e":"metro:bundling:progress","_t":1784254571678,"id":"1","progress":0.500828402366864,"total":2478,"current":1357}
|
||||
{"_e":"metro:bundling:progress","_t":1784254571778,"id":"1","progress":0.500828402366864,"total":2478,"current":1410}
|
||||
{"_e":"metro:bundling:progress","_t":1784254571878,"id":"1","progress":0.500828402366864,"total":2478,"current":1461}
|
||||
{"_e":"metro:bundling:progress","_t":1784254571978,"id":"1","progress":0.500828402366864,"total":2478,"current":1507}
|
||||
{"_e":"metro:bundling:progress","_t":1784254572078,"id":"1","progress":0.500828402366864,"total":2478,"current":1560}
|
||||
{"_e":"metro:bundling:progress","_t":1784254572178,"id":"1","progress":0.500828402366864,"total":2478,"current":1611}
|
||||
{"_e":"metro:bundling:progress","_t":1784254572279,"id":"1","progress":0.500828402366864,"total":2478,"current":1668}
|
||||
{"_e":"metro:bundling:progress","_t":1784254572379,"id":"1","progress":0.500828402366864,"total":2478,"current":1721}
|
||||
{"_e":"metro:bundling:progress","_t":1784254572480,"id":"1","progress":0.5136689550856253,"total":2478,"current":1776}
|
||||
{"_e":"metro:bundling:progress","_t":1784254572580,"id":"1","progress":0.5394363050209071,"total":2478,"current":1820}
|
||||
{"_e":"metro:bundling:progress","_t":1784254572681,"id":"1","progress":0.5737542838642687,"total":2478,"current":1877}
|
||||
{"_e":"metro:bundling:progress","_t":1784254572781,"id":"1","progress":0.6047290409029646,"total":2478,"current":1927}
|
||||
{"_e":"metro:bundling:progress","_t":1784254572881,"id":"1","progress":0.6397417532559323,"total":2478,"current":1982}
|
||||
{"_e":"metro:bundling:progress","_t":1784254572981,"id":"1","progress":0.6737507987969679,"total":2478,"current":2034}
|
||||
{"_e":"metro:bundling:progress","_t":1784254573081,"id":"1","progress":0.7106802981654216,"total":2478,"current":2089}
|
||||
{"_e":"metro:bundling:progress","_t":1784254573181,"id":"1","progress":0.7506914764373623,"total":2478,"current":2147}
|
||||
{"_e":"metro:bundling:progress","_t":1784254573281,"id":"1","progress":0.782489458485683,"total":2478,"current":2192}
|
||||
{"_e":"metro:bundling:progress","_t":1784254573382,"id":"1","progress":0.822981380620811,"total":2478,"current":2248}
|
||||
{"_e":"metro:bundling:progress","_t":1784254573483,"id":"1","progress":0.8637444540202369,"total":2478,"current":2303}
|
||||
{"_e":"metro:bundling:progress","_t":1784254573583,"id":"1","progress":0.8846124232278917,"total":2489,"current":2341}
|
||||
{"_e":"metro:bundling:progress","_t":1784254573683,"id":"1","progress":0.8880743173619988,"total":2499,"current":2355}
|
||||
{"_e":"metro:bundling:progress","_t":1784254573786,"id":"1","progress":0.8880743173619988,"total":2589,"current":2373}
|
||||
{"_e":"metro:bundling:progress","_t":1784254573886,"id":"1","progress":0.8880743173619988,"total":2601,"current":2399}
|
||||
{"_e":"metro:bundling:progress","_t":1784254573987,"id":"1","progress":0.8880743173619988,"total":2602,"current":2410}
|
||||
{"_e":"metro:bundling:progress","_t":1784254574087,"id":"1","progress":0.8880743173619988,"total":2614,"current":2424}
|
||||
{"_e":"metro:bundling:progress","_t":1784254574188,"id":"1","progress":0.8880743173619988,"total":2617,"current":2444}
|
||||
{"_e":"metro:bundling:progress","_t":1784254574288,"id":"1","progress":0.8880743173619988,"total":2619,"current":2451}
|
||||
{"_e":"metro:bundling:progress","_t":1784254574388,"id":"1","progress":0.8880743173619988,"total":2621,"current":2455}
|
||||
{"_e":"metro:bundling:progress","_t":1784254574488,"id":"1","progress":0.8880743173619988,"total":2625,"current":2462}
|
||||
{"_e":"metro:bundling:progress","_t":1784254574588,"id":"1","progress":0.8880743173619988,"total":2631,"current":2469}
|
||||
{"_e":"metro:bundling:progress","_t":1784254574688,"id":"1","progress":0.8880743173619988,"total":2636,"current":2474}
|
||||
{"_e":"metro:bundling:progress","_t":1784254574844,"id":"1","progress":0.8880743173619988,"total":2640,"current":2481}
|
||||
{"_e":"metro:bundling:progress","_t":1784254574944,"id":"1","progress":0.8880743173619988,"total":2649,"current":2493}
|
||||
{"_e":"metro:bundling:progress","_t":1784254575044,"id":"1","progress":0.8880743173619988,"total":2700,"current":2508}
|
||||
{"_e":"metro:bundling:progress","_t":1784254575144,"id":"1","progress":0.8880743173619988,"total":2707,"current":2526}
|
||||
{"_e":"metro:bundling:progress","_t":1784254575245,"id":"1","progress":0.8880743173619988,"total":2713,"current":2534}
|
||||
{"_e":"metro:bundling:progress","_t":1784254575345,"id":"1","progress":0.8880743173619988,"total":2778,"current":2556}
|
||||
{"_e":"metro:bundling:progress","_t":1784254575447,"id":"1","progress":0.8880743173619988,"total":2786,"current":2569}
|
||||
{"_e":"metro:bundling:progress","_t":1784254575547,"id":"1","progress":0.8880743173619988,"total":2787,"current":2572}
|
||||
{"_e":"metro:bundling:progress","_t":1784254575647,"id":"1","progress":0.8880743173619988,"total":2787,"current":2574}
|
||||
{"_e":"metro:bundling:progress","_t":1784254575778,"id":"1","progress":0.8880743173619988,"total":2787,"current":2575}
|
||||
{"_e":"metro:bundling:progress","_t":1784254575878,"id":"1","progress":0.8880743173619988,"total":2789,"current":2582}
|
||||
{"_e":"metro:bundling:progress","_t":1784254575980,"id":"1","progress":0.8880743173619988,"total":2789,"current":2593}
|
||||
{"_e":"metro:bundling:progress","_t":1784254576082,"id":"1","progress":0.8880743173619988,"total":2799,"current":2612}
|
||||
{"_e":"metro:bundling:progress","_t":1784254576182,"id":"1","progress":0.8880743173619988,"total":2813,"current":2637}
|
||||
{"_e":"metro:bundling:progress","_t":1784254576282,"id":"1","progress":0.8880743173619988,"total":2824,"current":2661}
|
||||
{"_e":"metro:bundling:progress","_t":1784254576384,"id":"1","progress":0.9156393512217658,"total":2830,"current":2708}
|
||||
{"_e":"metro:bundling:progress","_t":1784254576484,"id":"1","progress":0.9156393512217658,"total":2848,"current":2724}
|
||||
{"_e":"metro:bundling:progress","_t":1784254576584,"id":"1","progress":0.9156393512217658,"total":2849,"current":2726}
|
||||
{"_e":"metro:bundling:progress","_t":1784254576684,"id":"1","progress":0.9156393512217658,"total":2868,"current":2734}
|
||||
{"_e":"metro:bundling:progress","_t":1784254576827,"id":"1","progress":0.9156393512217658,"total":2868,"current":2735}
|
||||
{"_e":"metro:bundling:progress","_t":1784254576927,"id":"1","progress":0.9156393512217658,"total":2885,"current":2738}
|
||||
{"_e":"metro:bundling:progress","_t":1784254577029,"id":"1","progress":0.9156393512217658,"total":2885,"current":2741}
|
||||
{"_e":"metro:bundling:progress","_t":1784254577129,"id":"1","progress":0.9156393512217658,"total":2900,"current":2760}
|
||||
{"_e":"metro:bundling:progress","_t":1784254577229,"id":"1","progress":0.9242757760075363,"total":2901,"current":2789}
|
||||
{"_e":"metro:bundling:progress","_t":1784254577332,"id":"1","progress":0.936287891094404,"total":2903,"current":2809}
|
||||
{"_e":"metro:bundling:progress","_t":1784254577435,"id":"1","progress":0.936287891094404,"total":2938,"current":2819}
|
||||
{"_e":"metro:bundling:progress","_t":1784254577540,"id":"1","progress":0.936287891094404,"total":2986,"current":2825}
|
||||
{"_e":"metro:bundling:progress","_t":1784254577642,"id":"1","progress":0.936287891094404,"total":3015,"current":2842}
|
||||
{"_e":"metro:bundling:progress","_t":1784254577740,"id":"1","progress":0.936287891094404,"total":3030,"current":2856}
|
||||
{"_e":"metro:bundling:progress","_t":1784254577840,"id":"1","progress":0.936287891094404,"total":3035,"current":2860}
|
||||
{"_e":"metro:bundling:progress","_t":1784254577940,"id":"1","progress":0.936287891094404,"total":3044,"current":2871}
|
||||
{"_e":"metro:bundling:progress","_t":1784254578041,"id":"1","progress":0.936287891094404,"total":3047,"current":2879}
|
||||
{"_e":"metro:bundling:progress","_t":1784254578141,"id":"1","progress":0.936287891094404,"total":3065,"current":2890}
|
||||
{"_e":"metro:bundling:progress","_t":1784254578241,"id":"1","progress":0.936287891094404,"total":3080,"current":2900}
|
||||
{"_e":"metro:bundling:progress","_t":1784254578342,"id":"1","progress":0.936287891094404,"total":3082,"current":2913}
|
||||
{"_e":"metro:bundling:progress","_t":1784254578442,"id":"1","progress":0.936287891094404,"total":3082,"current":2928}
|
||||
{"_e":"metro:bundling:progress","_t":1784254578543,"id":"1","progress":0.936287891094404,"total":3089,"current":2949}
|
||||
{"_e":"metro:bundling:progress","_t":1784254578642,"id":"1","progress":0.936287891094404,"total":3096,"current":2973}
|
||||
{"_e":"metro:bundling:progress","_t":1784254578742,"id":"1","progress":0.936287891094404,"total":3114,"current":2999}
|
||||
{"_e":"metro:bundling:progress","_t":1784254578843,"id":"1","progress":0.936287891094404,"total":3114,"current":3002}
|
||||
{"_e":"metro:bundling:progress","_t":1784254578943,"id":"1","progress":0.936287891094404,"total":3123,"current":3009}
|
||||
{"_e":"metro:bundling:progress","_t":1784254579045,"id":"1","progress":0.936287891094404,"total":3123,"current":3011}
|
||||
{"_e":"metro:bundling:progress","_t":1784254579064,"id":"1","progress":0.936287891094404,"total":3123,"current":3012}
|
||||
{"_e":"metro:bundling:progress","_t":1784254579164,"id":"1","progress":0.936287891094404,"total":3131,"current":3025}
|
||||
{"_e":"metro:bundling:progress","_t":1784254579265,"id":"1","progress":0.9365842200881371,"total":3134,"current":3033}
|
||||
{"_e":"metro:bundling:progress","_t":1784254579367,"id":"1","progress":0.9368419128824654,"total":3147,"current":3046}
|
||||
{"_e":"metro:bundling:progress","_t":1784254579468,"id":"1","progress":0.9387456790123457,"total":3150,"current":3052}
|
||||
{"_e":"metro:bundling:progress","_t":1784254579568,"id":"1","progress":0.9393609473418997,"total":3150,"current":3053}
|
||||
{"_e":"metro:bundling:progress","_t":1784254579682,"id":"1","progress":0.9412079617031998,"total":3150,"current":3056}
|
||||
{"_e":"metro:bundling:progress","_t":1784254579782,"id":"1","progress":0.9430567901234569,"total":3150,"current":3059}
|
||||
{"_e":"metro:bundling:progress","_t":1784254579883,"id":"1","progress":0.9473777777777779,"total":3150,"current":3066}
|
||||
{"_e":"metro:bundling:progress","_t":1784254579989,"id":"1","progress":0.9529773417570666,"total":3152,"current":3077}
|
||||
{"_e":"metro:bundling:progress","_t":1784254580089,"id":"1","progress":0.9549354307189066,"total":3159,"current":3087}
|
||||
{"_e":"metro:bundling:progress","_t":1784254580189,"id":"1","progress":0.9686047908988945,"total":3160,"current":3110}
|
||||
{"_e":"metro:bundling:progress","_t":1784254580292,"id":"1","progress":0.9717926782024794,"total":3168,"current":3123}
|
||||
{"_e":"metro:bundling:progress","_t":1784254580392,"id":"1","progress":0.9767778200247423,"total":3168,"current":3131}
|
||||
{"_e":"metro:bundling:progress","_t":1784254580502,"id":"1","progress":0.9768938323842832,"total":3184,"current":3147}
|
||||
{"_e":"metro:bundling:progress","_t":1784254580608,"id":"1","progress":0.9769943965134558,"total":3198,"current":3161}
|
||||
{"_e":"metro:bundling:progress","_t":1784254580709,"id":"1","progress":0.9769943965134558,"total":3220,"current":3182}
|
||||
{"_e":"metro:bundling:progress","_t":1784254580809,"id":"1","progress":0.9791352776121244,"total":3242,"current":3208}
|
||||
{"_e":"metro:bundling:progress","_t":1784254580909,"id":"1","progress":0.9791352776121244,"total":3272,"current":3232}
|
||||
{"_e":"metro:bundling:progress","_t":1784254581010,"id":"1","progress":0.9848326177487721,"total":3284,"current":3259}
|
||||
{"_e":"metro:bundling:progress","_t":1784254581111,"id":"1","progress":0.999,"total":3291,"current":3290}
|
||||
{"_e":"metro:bundling:progress","_t":1784254581211,"id":"1","progress":0.999,"total":3291,"current":3291}
|
||||
{"_e":"metro:bundling:done","_t":1784254581468,"id":"1","total":3291,"ms":16753.428873}
|
||||
{"_e":"metro:client_log","_t":1784254596888,"level":"warn","data":["SafeAreaView has been deprecated and will be removed in a future release. Please use 'react-native-safe-area-context' instead. See https://github.com/AppAndFlow/react-native-safe-area-context"]}
|
||||
{"_e":"metro:bundling:started","_t":1784254612886,"id":"3","platform":"android","environment":null,"entry":"index.ts"}
|
||||
{"_e":"metro:bundling:progress","_t":1784254612886,"id":"3","progress":0,"total":1,"current":0}
|
||||
{"_e":"metro:bundling:done","_t":1784254612993,"id":"3","total":1,"ms":107.061183}
|
||||
{"_e":"metro:client_log","_t":1784254630240,"level":"warn","data":["SafeAreaView has been deprecated and will be removed in a future release. Please use 'react-native-safe-area-context' instead. See https://github.com/AppAndFlow/react-native-safe-area-context"]}
|
||||
{"_e":"metro:client_log","_t":1784254721520,"level":"warn","data":["[expo-image-picker] `ImagePicker.MediaTypeOptions` have been deprecated. Use `ImagePicker.MediaType` or an array of `ImagePicker.MediaType` instead."]}
|
||||
{"_e":"metro:client_log","_t":1784254774528,"level":"warn","data":["[expo-image-picker] `ImagePicker.MediaTypeOptions` have been deprecated. Use `ImagePicker.MediaType` or an array of `ImagePicker.MediaType` instead."]}
|
||||
{"_e":"metro:client_log","_t":1784254928412,"level":"warn","data":["[expo-image-picker] `ImagePicker.MediaTypeOptions` have been deprecated. Use `ImagePicker.MediaType` or an array of `ImagePicker.MediaType` instead."]}
|
||||
{"_e":"metro:bundling:started","_t":1784256200326,"id":"7","platform":"android","environment":null,"entry":"index.ts"}
|
||||
{"_e":"metro:bundling:progress","_t":1784256200326,"id":"7","progress":0,"total":1,"current":0}
|
||||
{"_e":"metro:bundling:progress","_t":1784256200332,"id":"7","progress":0,"total":1,"current":0}
|
||||
{"_e":"metro:bundling:progress","_t":1784256200434,"id":"7","progress":0,"total":1,"current":0}
|
||||
{"_e":"metro:bundling:done","_t":1784256200538,"id":"7","total":1,"ms":211.987279}
|
||||
{"_e":"metro:client_log","_t":1784256223781,"level":"warn","data":["SafeAreaView has been deprecated and will be removed in a future release. Please use 'react-native-safe-area-context' instead. See https://github.com/AppAndFlow/react-native-safe-area-context"]}
|
||||
{"_e":"metro:client_log","_t":1784256310429,"level":"warn","data":["[expo-image-picker] `ImagePicker.MediaTypeOptions` have been deprecated. Use `ImagePicker.MediaType` or an array of `ImagePicker.MediaType` instead."]}
|
||||
{"_e":"metro:client_log","_t":1784256369062,"level":"warn","data":["[expo-image-picker] `ImagePicker.MediaTypeOptions` have been deprecated. Use `ImagePicker.MediaType` or an array of `ImagePicker.MediaType` instead."]}
|
||||
{"_e":"metro:client_log","_t":1784256387012,"level":"warn","data":["[expo-image-picker] `ImagePicker.MediaTypeOptions` have been deprecated. Use `ImagePicker.MediaType` or an array of `ImagePicker.MediaType` instead."]}
|
||||
{"_e":"metro:client_log","_t":1784256420271,"level":"warn","data":["[expo-image-picker] `ImagePicker.MediaTypeOptions` have been deprecated. Use `ImagePicker.MediaType` or an array of `ImagePicker.MediaType` instead."]}
|
||||
{"_e":"metro:bundling:started","_t":1784258596497,"id":"9","platform":"android","environment":null,"entry":"index.ts"}
|
||||
{"_e":"metro:bundling:progress","_t":1784258596520,"id":"9","progress":0,"total":1,"current":0}
|
||||
{"_e":"metro:bundling:progress","_t":1784258596532,"id":"9","progress":0,"total":1,"current":0}
|
||||
{"_e":"metro:bundling:progress","_t":1784258596843,"id":"9","progress":0,"total":1,"current":0}
|
||||
{"_e":"metro:bundling:progress","_t":1784258597094,"id":"9","progress":0,"total":1,"current":0}
|
||||
{"_e":"metro:bundling:progress","_t":1784258597194,"id":"9","progress":0,"total":1,"current":0}
|
||||
{"_e":"metro:bundling:progress","_t":1784258597294,"id":"9","progress":0,"total":1,"current":0}
|
||||
{"_e":"metro:bundling:progress","_t":1784258597394,"id":"9","progress":0,"total":1,"current":0}
|
||||
{"_e":"metro:bundling:progress","_t":1784258597494,"id":"9","progress":0,"total":1,"current":0}
|
||||
{"_e":"metro:bundling:progress","_t":1784258597594,"id":"9","progress":0,"total":1,"current":0}
|
||||
{"_e":"metro:bundling:progress","_t":1784258597694,"id":"9","progress":0,"total":1,"current":0}
|
||||
{"_e":"metro:bundling:progress","_t":1784258597809,"id":"9","progress":0,"total":1,"current":0}
|
||||
{"_e":"metro:bundling:progress","_t":1784258597925,"id":"9","progress":0,"total":1,"current":0}
|
||||
{"_e":"metro:bundling:progress","_t":1784258598025,"id":"9","progress":0,"total":1,"current":0}
|
||||
{"_e":"metro:bundling:done","_t":1784258598321,"id":"9","total":1,"ms":1802.095472}
|
||||
{"_e":"metro:client_log","_t":1784258647493,"level":"warn","data":["[expo-image-picker] `ImagePicker.MediaTypeOptions` have been deprecated. Use `ImagePicker.MediaType` or an array of `ImagePicker.MediaType` instead."]}
|
||||
{"_e":"metro:bundling:started","_t":1784261637702,"id":"b","platform":"android","environment":null,"entry":"index.ts"}
|
||||
{"_e":"metro:bundling:progress","_t":1784261637702,"id":"b","progress":0,"total":1,"current":0}
|
||||
{"_e":"metro:bundling:progress","_t":1784261637708,"id":"b","progress":0,"total":1,"current":0}
|
||||
{"_e":"metro:bundling:progress","_t":1784261637808,"id":"b","progress":0,"total":1,"current":0}
|
||||
{"_e":"metro:bundling:progress","_t":1784261638016,"id":"b","progress":0,"total":1,"current":0}
|
||||
{"_e":"metro:bundling:progress","_t":1784261638197,"id":"b","progress":0,"total":1,"current":0}
|
||||
{"_e":"metro:bundling:done","_t":1784261638447,"id":"b","total":1,"ms":744.986372}
|
||||
{"_e":"metro:client_log","_t":1784261670156,"level":"warn","data":["[expo-image-picker] `ImagePicker.MediaTypeOptions` have been deprecated. Use `ImagePicker.MediaType` or an array of `ImagePicker.MediaType` instead."]}
|
||||
{"_e":"metro:client_log","_t":1784261697357,"level":"warn","data":["[expo-image-picker] `ImagePicker.MediaTypeOptions` have been deprecated. Use `ImagePicker.MediaType` or an array of `ImagePicker.MediaType` instead."]}
|
||||
{"_e":"metro:bundling:started","_t":1784263415011,"id":"d","platform":"android","environment":null,"entry":"index.ts"}
|
||||
{"_e":"metro:bundling:progress","_t":1784263415013,"id":"d","progress":0,"total":1,"current":0}
|
||||
{"_e":"metro:bundling:progress","_t":1784263415016,"id":"d","progress":0,"total":1,"current":0}
|
||||
{"_e":"metro:bundling:progress","_t":1784263415116,"id":"d","progress":0,"total":1,"current":0}
|
||||
{"_e":"metro:bundling:progress","_t":1784263415858,"id":"d","progress":0,"total":1,"current":0}
|
||||
{"_e":"metro:bundling:done","_t":1784263416167,"id":"d","total":1,"ms":1153.731646}
|
||||
{"_e":"metro:bundling:started","_t":1784266840468,"id":"f","platform":"android","environment":null,"entry":"index.ts"}
|
||||
{"_e":"metro:bundling:progress","_t":1784266840469,"id":"f","progress":0,"total":1,"current":0}
|
||||
{"_e":"metro:bundling:progress","_t":1784266840484,"id":"f","progress":0,"total":1,"current":0}
|
||||
{"_e":"metro:bundling:progress","_t":1784266840743,"id":"f","progress":0,"total":1,"current":0}
|
||||
{"_e":"metro:bundling:progress","_t":1784266840992,"id":"f","progress":0,"total":1,"current":0}
|
||||
{"_e":"metro:bundling:progress","_t":1784266841361,"id":"f","progress":0,"total":1,"current":0}
|
||||
{"_e":"metro:bundling:progress","_t":1784266841461,"id":"f","progress":0,"total":1,"current":0}
|
||||
{"_e":"metro:bundling:progress","_t":1784266841561,"id":"f","progress":0,"total":1,"current":0}
|
||||
{"_e":"metro:bundling:progress","_t":1784266841661,"id":"f","progress":0,"total":1,"current":0}
|
||||
{"_e":"metro:bundling:progress","_t":1784266841761,"id":"f","progress":0,"total":1,"current":0}
|
||||
{"_e":"metro:bundling:progress","_t":1784266841861,"id":"f","progress":0,"total":1,"current":0}
|
||||
{"_e":"metro:bundling:progress","_t":1784266841961,"id":"f","progress":0,"total":1,"current":0}
|
||||
{"_e":"metro:bundling:progress","_t":1784266842061,"id":"f","progress":0,"total":1,"current":0}
|
||||
{"_e":"metro:bundling:progress","_t":1784266842173,"id":"f","progress":0,"total":1,"current":0}
|
||||
{"_e":"metro:bundling:progress","_t":1784266842275,"id":"f","progress":0,"total":1,"current":0}
|
||||
{"_e":"metro:bundling:progress","_t":1784266842846,"id":"f","progress":0,"total":1,"current":0}
|
||||
{"_e":"metro:bundling:progress","_t":1784266843171,"id":"f","progress":0,"total":1,"current":0}
|
||||
{"_e":"metro:bundling:progress","_t":1784266843304,"id":"f","progress":0,"total":1,"current":0}
|
||||
{"_e":"metro:bundling:progress","_t":1784266843413,"id":"f","progress":0,"total":1,"current":0}
|
||||
{"_e":"metro:bundling:progress","_t":1784266844439,"id":"f","progress":0,"total":1,"current":0}
|
||||
{"_e":"metro:bundling:progress","_t":1784266844538,"id":"f","progress":0,"total":1,"current":0}
|
||||
{"_e":"metro:bundling:progress","_t":1784266844818,"id":"f","progress":0,"total":1,"current":0}
|
||||
{"_e":"metro:bundling:progress","_t":1784266844918,"id":"f","progress":0,"total":1,"current":0}
|
||||
{"_e":"metro:bundling:progress","_t":1784266844939,"id":"f","progress":0,"total":1,"current":0}
|
||||
{"_e":"metro:bundling:progress","_t":1784266845040,"id":"f","progress":0,"total":1,"current":0}
|
||||
{"_e":"metro:bundling:progress","_t":1784266845141,"id":"f","progress":0,"total":1,"current":0}
|
||||
{"_e":"metro:bundling:progress","_t":1784266845241,"id":"f","progress":0,"total":1,"current":0}
|
||||
{"_e":"metro:bundling:progress","_t":1784266845341,"id":"f","progress":0,"total":1,"current":0}
|
||||
{"_e":"metro:bundling:progress","_t":1784266845443,"id":"f","progress":0,"total":1,"current":0}
|
||||
{"_e":"metro:bundling:progress","_t":1784266845546,"id":"f","progress":0,"total":1,"current":0}
|
||||
{"_e":"metro:bundling:progress","_t":1784266845580,"id":"f","progress":0,"total":1,"current":0}
|
||||
{"_e":"metro:bundling:done","_t":1784266846113,"id":"f","total":1,"ms":5644.099564}
|
||||
{"_e":"metro:client_log","_t":1784266851912,"level":"error","data":["[TypeError: undefined is not a function]"]}
|
||||
{"_e":"root:init","_t":1784293044660,"format":"v0-jsonl","version":"57.0.8"}
|
||||
{"_e":"env:mode","_t":1784293044688,"nodeEnv":"development","babelEnv":"development","mode":"development"}
|
||||
{"_e":"env:load","_t":1784293044699,"mode":"development","files":[],"env":{}}
|
||||
{"_e":"devserver:start","_t":1784293048866,"mode":"development","web":true,"baseUrl":"","asyncRoutes":false,"routerRoot":"../../../app","serverComponents":false,"serverActions":false,"serverRendering":false,"apiRoutes":false,"exporting":false}
|
||||
{"_e":"metro:config","_t":1784293054547,"serverRoot":"../../..","projectRoot":"../../..","exporting":false,"flags":{"autolinkingModuleResolution":false,"serverActions":false,"serverComponents":false,"reactCompiler":false,"optimizeGraph":false,"treeshaking":false,"logbox":false}}
|
||||
{"_e":"expo:dev-tools-plugin:load","_t":1784293056345,"plugins":[]}
|
||||
{"_e":"metro:instantiate","_t":1784293066659,"atlas":false,"workers":6,"host":"::","port":8081}
|
||||
{"_e":"metro:bundling:started","_t":1784293282277,"id":"1","platform":"android","environment":null,"entry":"index.ts"}
|
||||
{"_e":"metro:bundling:progress","_t":1784293282278,"id":"1","progress":0,"total":1,"current":0}
|
||||
{"_e":"metro:bundling:progress","_t":1784293282305,"id":"1","progress":0,"total":1,"current":0}
|
||||
{"_e":"metro:bundling:progress","_t":1784293284527,"id":"1","progress":0.010000000000000002,"total":1,"current":1}
|
||||
{"_e":"metro:bundling:progress","_t":1784293284627,"id":"1","progress":0.09,"total":9,"current":3}
|
||||
{"_e":"metro:bundling:progress","_t":1784293284739,"id":"1","progress":0.25,"total":12,"current":6}
|
||||
{"_e":"metro:bundling:progress","_t":1784293284907,"id":"1","progress":0.34027777777777785,"total":12,"current":7}
|
||||
{"_e":"metro:bundling:progress","_t":1784293285240,"id":"1","progress":0.4444444444444444,"total":12,"current":8}
|
||||
{"_e":"metro:bundling:progress","_t":1784293285340,"id":"1","progress":0.4444444444444444,"total":60,"current":12}
|
||||
{"_e":"metro:bundling:progress","_t":1784293285447,"id":"1","progress":0.4444444444444444,"total":60,"current":13}
|
||||
{"_e":"metro:bundling:progress","_t":1784293285550,"id":"1","progress":0.4444444444444444,"total":65,"current":19}
|
||||
{"_e":"metro:bundling:progress","_t":1784293285649,"id":"1","progress":0.4444444444444444,"total":77,"current":41}
|
||||
{"_e":"metro:bundling:progress","_t":1784293285750,"id":"1","progress":0.4444444444444444,"total":94,"current":52}
|
||||
{"_e":"metro:bundling:progress","_t":1784293285850,"id":"1","progress":0.4444444444444444,"total":123,"current":67}
|
||||
{"_e":"metro:bundling:progress","_t":1784293285950,"id":"1","progress":0.4444444444444444,"total":133,"current":83}
|
||||
{"_e":"metro:bundling:progress","_t":1784293286050,"id":"1","progress":0.4444444444444444,"total":155,"current":92}
|
||||
{"_e":"metro:bundling:progress","_t":1784293286155,"id":"1","progress":0.4444444444444444,"total":186,"current":105}
|
||||
{"_e":"metro:bundling:progress","_t":1784293286255,"id":"1","progress":0.4444444444444444,"total":186,"current":110}
|
||||
{"_e":"metro:bundling:progress","_t":1784293286355,"id":"1","progress":0.4444444444444444,"total":193,"current":119}
|
||||
{"_e":"metro:bundling:progress","_t":1784293286471,"id":"1","progress":0.4444444444444444,"total":200,"current":129}
|
||||
{"_e":"metro:bundling:progress","_t":1784293286534,"id":"1","progress":0.4444444444444444,"total":200,"current":130}
|
||||
{"_e":"metro:bundling:progress","_t":1784293286653,"id":"1","progress":0.4444444444444444,"total":232,"current":142}
|
||||
{"_e":"metro:bundling:progress","_t":1784293286753,"id":"1","progress":0.4444444444444444,"total":328,"current":149}
|
||||
{"_e":"metro:bundling:progress","_t":1784293286928,"id":"1","progress":0.4444444444444444,"total":328,"current":150}
|
||||
{"_e":"metro:bundling:progress","_t":1784293287051,"id":"1","progress":0.4444444444444444,"total":2077,"current":151}
|
||||
{"_e":"metro:bundling:progress","_t":1784293287151,"id":"1","progress":0.4444444444444444,"total":2091,"current":183}
|
||||
{"_e":"metro:bundling:progress","_t":1784293287251,"id":"1","progress":0.4444444444444444,"total":2105,"current":198}
|
||||
{"_e":"metro:bundling:progress","_t":1784293287351,"id":"1","progress":0.4444444444444444,"total":2172,"current":215}
|
||||
{"_e":"metro:bundling:progress","_t":1784293287465,"id":"1","progress":0.4444444444444444,"total":2225,"current":237}
|
||||
{"_e":"metro:bundling:progress","_t":1784293287568,"id":"1","progress":0.4444444444444444,"total":2248,"current":253}
|
||||
{"_e":"metro:bundling:progress","_t":1784293287668,"id":"1","progress":0.4444444444444444,"total":2271,"current":275}
|
||||
{"_e":"metro:bundling:progress","_t":1784293287768,"id":"1","progress":0.4444444444444444,"total":2309,"current":306}
|
||||
{"_e":"metro:bundling:progress","_t":1784293287868,"id":"1","progress":0.4444444444444444,"total":2322,"current":326}
|
||||
{"_e":"metro:bundling:progress","_t":1784293287990,"id":"1","progress":0.4444444444444444,"total":2322,"current":327}
|
||||
{"_e":"metro:bundling:progress","_t":1784293288090,"id":"1","progress":0.4444444444444444,"total":2326,"current":368}
|
||||
{"_e":"metro:bundling:progress","_t":1784293288198,"id":"1","progress":0.4444444444444444,"total":2326,"current":410}
|
||||
{"_e":"metro:bundling:progress","_t":1784293288305,"id":"1","progress":0.4444444444444444,"total":2326,"current":438}
|
||||
{"_e":"metro:bundling:progress","_t":1784293288405,"id":"1","progress":0.4444444444444444,"total":2326,"current":466}
|
||||
{"_e":"metro:bundling:progress","_t":1784293288506,"id":"1","progress":0.4444444444444444,"total":2326,"current":531}
|
||||
{"_e":"metro:bundling:progress","_t":1784293288606,"id":"1","progress":0.4444444444444444,"total":2326,"current":588}
|
||||
{"_e":"metro:bundling:progress","_t":1784293288707,"id":"1","progress":0.4444444444444444,"total":2326,"current":647}
|
||||
{"_e":"metro:bundling:progress","_t":1784293288807,"id":"1","progress":0.4444444444444444,"total":2326,"current":713}
|
||||
{"_e":"metro:bundling:progress","_t":1784293288908,"id":"1","progress":0.4444444444444444,"total":2326,"current":780}
|
||||
{"_e":"metro:bundling:progress","_t":1784293289008,"id":"1","progress":0.4444444444444444,"total":2326,"current":850}
|
||||
{"_e":"metro:bundling:progress","_t":1784293289108,"id":"1","progress":0.4444444444444444,"total":2326,"current":907}
|
||||
{"_e":"metro:bundling:progress","_t":1784293289211,"id":"1","progress":0.4444444444444444,"total":2326,"current":969}
|
||||
{"_e":"metro:bundling:progress","_t":1784293289311,"id":"1","progress":0.4444444444444444,"total":2326,"current":1040}
|
||||
{"_e":"metro:bundling:progress","_t":1784293289411,"id":"1","progress":0.4444444444444444,"total":2326,"current":1108}
|
||||
{"_e":"metro:bundling:progress","_t":1784293289511,"id":"1","progress":0.4444444444444444,"total":2326,"current":1179}
|
||||
{"_e":"metro:bundling:progress","_t":1784293289611,"id":"1","progress":0.4444444444444444,"total":2326,"current":1239}
|
||||
{"_e":"metro:bundling:progress","_t":1784293289711,"id":"1","progress":0.4444444444444444,"total":2326,"current":1307}
|
||||
{"_e":"metro:bundling:progress","_t":1784293289811,"id":"1","progress":0.4444444444444444,"total":2326,"current":1375}
|
||||
{"_e":"metro:bundling:progress","_t":1784293289911,"id":"1","progress":0.4444444444444444,"total":2326,"current":1447}
|
||||
{"_e":"metro:bundling:progress","_t":1784293290011,"id":"1","progress":0.4444444444444444,"total":2326,"current":1508}
|
||||
{"_e":"metro:bundling:progress","_t":1784293290111,"id":"1","progress":0.4614182344856344,"total":2326,"current":1580}
|
||||
{"_e":"metro:bundling:progress","_t":1784293290211,"id":"1","progress":0.49289333852838557,"total":2326,"current":1633}
|
||||
{"_e":"metro:bundling:progress","_t":1784293290311,"id":"1","progress":0.5347973005443715,"total":2326,"current":1701}
|
||||
{"_e":"metro:bundling:progress","_t":1784293290412,"id":"1","progress":0.5738420738609268,"total":2326,"current":1762}
|
||||
{"_e":"metro:bundling:progress","_t":1784293290512,"id":"1","progress":0.6115697239845065,"total":2326,"current":1819}
|
||||
{"_e":"metro:bundling:progress","_t":1784293290613,"id":"1","progress":0.6665465865327388,"total":2326,"current":1899}
|
||||
{"_e":"metro:bundling:progress","_t":1784293290713,"id":"1","progress":0.7100561967633445,"total":2326,"current":1960}
|
||||
{"_e":"metro:bundling:progress","_t":1784293290813,"id":"1","progress":0.7594305724883536,"total":2326,"current":2027}
|
||||
{"_e":"metro:bundling:progress","_t":1784293290913,"id":"1","progress":0.7974657485960396,"total":2346,"current":2095}
|
||||
{"_e":"metro:bundling:progress","_t":1784293291013,"id":"1","progress":0.7974657485960396,"total":2415,"current":2124}
|
||||
{"_e":"metro:bundling:progress","_t":1784293291115,"id":"1","progress":0.7974657485960396,"total":2472,"current":2162}
|
||||
{"_e":"metro:bundling:progress","_t":1784293291215,"id":"1","progress":0.7974657485960396,"total":2474,"current":2174}
|
||||
{"_e":"metro:bundling:progress","_t":1784293291315,"id":"1","progress":0.7974657485960396,"total":2519,"current":2215}
|
||||
{"_e":"metro:bundling:progress","_t":1784293291416,"id":"1","progress":0.7974657485960396,"total":2536,"current":2243}
|
||||
{"_e":"metro:bundling:progress","_t":1784293291519,"id":"1","progress":0.7974657485960396,"total":2569,"current":2280}
|
||||
{"_e":"metro:bundling:progress","_t":1784293291628,"id":"1","progress":0.7974657485960396,"total":2606,"current":2318}
|
||||
{"_e":"metro:bundling:progress","_t":1784293291728,"id":"1","progress":0.7974657485960396,"total":2659,"current":2329}
|
||||
{"_e":"metro:bundling:progress","_t":1784293291831,"id":"1","progress":0.7974657485960396,"total":2675,"current":2348}
|
||||
{"_e":"metro:bundling:progress","_t":1784293291931,"id":"1","progress":0.7974657485960396,"total":2687,"current":2378}
|
||||
{"_e":"metro:bundling:progress","_t":1784293292033,"id":"1","progress":0.7974657485960396,"total":2707,"current":2408}
|
||||
{"_e":"metro:bundling:progress","_t":1784293292133,"id":"1","progress":0.8159888890401819,"total":2710,"current":2448}
|
||||
{"_e":"metro:bundling:progress","_t":1784293292234,"id":"1","progress":0.8444485836968604,"total":2714,"current":2494}
|
||||
{"_e":"metro:bundling:progress","_t":1784293292333,"id":"1","progress":0.864934254482803,"total":2715,"current":2525}
|
||||
{"_e":"metro:bundling:progress","_t":1784293292433,"id":"1","progress":0.8762830037348633,"total":2723,"current":2549}
|
||||
{"_e":"metro:bundling:progress","_t":1784293292536,"id":"1","progress":0.8864205287942541,"total":2735,"current":2575}
|
||||
{"_e":"metro:bundling:progress","_t":1784293292636,"id":"1","progress":0.8979424496100379,"total":2748,"current":2604}
|
||||
{"_e":"metro:bundling:progress","_t":1784293292736,"id":"1","progress":0.9052557235874816,"total":2760,"current":2626}
|
||||
{"_e":"metro:bundling:progress","_t":1784293292836,"id":"1","progress":0.9052557235874816,"total":2779,"current":2637}
|
||||
{"_e":"metro:bundling:progress","_t":1784293292936,"id":"1","progress":0.9052557235874816,"total":2808,"current":2645}
|
||||
{"_e":"metro:bundling:progress","_t":1784293293039,"id":"1","progress":0.9052557235874816,"total":2817,"current":2655}
|
||||
{"_e":"metro:bundling:progress","_t":1784293293138,"id":"1","progress":0.9052557235874816,"total":2833,"current":2669}
|
||||
{"_e":"metro:bundling:progress","_t":1784293293239,"id":"1","progress":0.9052557235874816,"total":2843,"current":2686}
|
||||
{"_e":"metro:bundling:progress","_t":1784293293339,"id":"1","progress":0.9052557235874816,"total":2851,"current":2705}
|
||||
{"_e":"metro:bundling:progress","_t":1784293293439,"id":"1","progress":0.9066178148700104,"total":2864,"current":2727}
|
||||
{"_e":"metro:bundling:progress","_t":1784293293541,"id":"1","progress":0.9148469566887284,"total":2872,"current":2747}
|
||||
{"_e":"metro:bundling:progress","_t":1784293293641,"id":"1","progress":0.9148469566887284,"total":2901,"current":2764}
|
||||
{"_e":"metro:bundling:progress","_t":1784293293742,"id":"1","progress":0.9148469566887284,"total":2901,"current":2767}
|
||||
{"_e":"metro:bundling:progress","_t":1784293293842,"id":"1","progress":0.9148469566887284,"total":2913,"current":2773}
|
||||
{"_e":"metro:bundling:progress","_t":1784293293942,"id":"1","progress":0.9148469566887284,"total":2914,"current":2775}
|
||||
{"_e":"metro:bundling:progress","_t":1784293294081,"id":"1","progress":0.9148469566887284,"total":2915,"current":2779}
|
||||
{"_e":"metro:bundling:progress","_t":1784293294182,"id":"1","progress":0.9148469566887284,"total":2928,"current":2791}
|
||||
{"_e":"metro:bundling:progress","_t":1784293294282,"id":"1","progress":0.9148469566887284,"total":2949,"current":2805}
|
||||
{"_e":"metro:bundling:progress","_t":1784293294382,"id":"1","progress":0.9148469566887284,"total":2955,"current":2815}
|
||||
{"_e":"metro:bundling:progress","_t":1784293294482,"id":"1","progress":0.9148469566887284,"total":2998,"current":2824}
|
||||
{"_e":"metro:bundling:progress","_t":1784293294582,"id":"1","progress":0.9148469566887284,"total":3048,"current":2844}
|
||||
{"_e":"metro:bundling:progress","_t":1784293294682,"id":"1","progress":0.9148469566887284,"total":3050,"current":2853}
|
||||
{"_e":"metro:bundling:progress","_t":1784293294783,"id":"1","progress":0.9148469566887284,"total":3071,"current":2877}
|
||||
{"_e":"metro:bundling:progress","_t":1784293294884,"id":"1","progress":0.9148469566887284,"total":3091,"current":2891}
|
||||
{"_e":"metro:bundling:progress","_t":1784293294988,"id":"1","progress":0.9148469566887284,"total":3097,"current":2900}
|
||||
{"_e":"metro:bundling:progress","_t":1784293295088,"id":"1","progress":0.9148469566887284,"total":3108,"current":2914}
|
||||
{"_e":"metro:bundling:progress","_t":1784293295188,"id":"1","progress":0.9148469566887284,"total":3121,"current":2926}
|
||||
{"_e":"metro:bundling:progress","_t":1784293295288,"id":"1","progress":0.9148469566887284,"total":3123,"current":2935}
|
||||
{"_e":"metro:bundling:progress","_t":1784293295389,"id":"1","progress":0.9148469566887284,"total":3123,"current":2937}
|
||||
{"_e":"metro:bundling:progress","_t":1784293295489,"id":"1","progress":0.9148469566887284,"total":3123,"current":2943}
|
||||
{"_e":"metro:bundling:progress","_t":1784293295589,"id":"1","progress":0.9148469566887284,"total":3124,"current":2952}
|
||||
{"_e":"metro:bundling:progress","_t":1784293295689,"id":"1","progress":0.9148469566887284,"total":3125,"current":2974}
|
||||
{"_e":"metro:bundling:progress","_t":1784293295789,"id":"1","progress":0.9148469566887284,"total":3125,"current":2987}
|
||||
{"_e":"metro:bundling:progress","_t":1784293295892,"id":"1","progress":0.9198330727820985,"total":3128,"current":3000}
|
||||
{"_e":"metro:bundling:progress","_t":1784293295992,"id":"1","progress":0.9299565847185871,"total":3141,"current":3029}
|
||||
{"_e":"metro:bundling:progress","_t":1784293296094,"id":"1","progress":0.9492962002534049,"total":3154,"current":3073}
|
||||
{"_e":"metro:bundling:progress","_t":1784293296194,"id":"1","progress":0.9629720079436931,"total":3157,"current":3098}
|
||||
{"_e":"metro:bundling:progress","_t":1784293296294,"id":"1","progress":0.9629720079436931,"total":3173,"current":3108}
|
||||
{"_e":"metro:bundling:progress","_t":1784293296394,"id":"1","progress":0.9629720079436931,"total":3185,"current":3124}
|
||||
{"_e":"metro:bundling:progress","_t":1784293296494,"id":"1","progress":0.9648012926893179,"total":3210,"current":3153}
|
||||
{"_e":"metro:bundling:progress","_t":1784293296594,"id":"1","progress":0.9747663656275425,"total":3229,"current":3188}
|
||||
{"_e":"metro:bundling:progress","_t":1784293296694,"id":"1","progress":0.9834384092814489,"total":3247,"current":3220}
|
||||
{"_e":"metro:bundling:progress","_t":1784293296794,"id":"1","progress":0.9834384092814489,"total":3286,"current":3254}
|
||||
{"_e":"metro:bundling:progress","_t":1784293296903,"id":"1","progress":0.999,"total":3300,"current":3300}
|
||||
{"_e":"metro:bundling:done","_t":1784293297284,"id":"1","total":3300,"ms":15006.607121}
|
||||
{"_e":"metro:client_log","_t":1784293311557,"level":"error","data":["[Error: [Reanimated] Failed to find host instance for a ref.]","\n\n\u001b[1mCall Stack\u001b[22m\n\u001b[2m\u001b[90m SkiaPictureView (\u001b]8;;<anonymous>\u0007<anonymous>\u001b]8;;\u0007)\u001b[39m\u001b[22m\n\u001b[2m\u001b[90m Canvas (\u001b]8;;http:/192.168.1.25:8081/index.ts.bundle\u0007http:/192.168.1.25:8081/index.ts.bundle\u001b]8;;\u0007)\u001b[39m\u001b[22m"]}
|
||||
{"_e":"metro:bundling:started","_t":1784294061559,"id":"9","platform":"android","environment":null,"entry":"index.ts"}
|
||||
{"_e":"metro:bundling:progress","_t":1784294061559,"id":"9","progress":0,"total":1,"current":0}
|
||||
{"_e":"metro:bundling:done","_t":1784294061634,"id":"9","total":1,"ms":75.165257}
|
||||
{"_e":"metro:bundling:started","_t":1784295273557,"id":"b","platform":"android","environment":null,"entry":"index.ts"}
|
||||
{"_e":"metro:bundling:progress","_t":1784295273557,"id":"b","progress":0,"total":1,"current":0}
|
||||
{"_e":"metro:bundling:progress","_t":1784295273562,"id":"b","progress":0,"total":1,"current":0}
|
||||
{"_e":"metro:bundling:done","_t":1784295273724,"id":"b","total":1,"ms":167.330607}
|
||||
{"_e":"metro:bundling:started","_t":1784296589113,"id":"d","platform":"android","environment":null,"entry":"index.ts"}
|
||||
{"_e":"metro:bundling:progress","_t":1784296589113,"id":"d","progress":0,"total":1,"current":0}
|
||||
{"_e":"metro:bundling:progress","_t":1784296589124,"id":"d","progress":0,"total":1,"current":0}
|
||||
{"_e":"metro:bundling:progress","_t":1784296589255,"id":"d","progress":0,"total":1,"current":0}
|
||||
{"_e":"metro:bundling:progress","_t":1784296589338,"id":"d","progress":0,"total":1,"current":0}
|
||||
{"_e":"metro:bundling:progress","_t":1784296589438,"id":"d","progress":0,"total":1,"current":0}
|
||||
{"_e":"metro:bundling:progress","_t":1784296589538,"id":"d","progress":0,"total":1,"current":0}
|
||||
{"_e":"metro:bundling:progress","_t":1784296589638,"id":"d","progress":0,"total":1,"current":0}
|
||||
{"_e":"metro:bundling:progress","_t":1784296589738,"id":"d","progress":0,"total":1,"current":0}
|
||||
{"_e":"metro:bundling:progress","_t":1784296589852,"id":"d","progress":0,"total":1,"current":0}
|
||||
{"_e":"metro:bundling:progress","_t":1784296589952,"id":"d","progress":0,"total":1,"current":0}
|
||||
{"_e":"metro:bundling:progress","_t":1784296590056,"id":"d","progress":0,"total":1,"current":0}
|
||||
{"_e":"metro:bundling:progress","_t":1784296590156,"id":"d","progress":0,"total":1,"current":0}
|
||||
{"_e":"metro:bundling:progress","_t":1784296590256,"id":"d","progress":0,"total":1,"current":0}
|
||||
{"_e":"metro:bundling:progress","_t":1784296590304,"id":"d","progress":0,"total":1,"current":0}
|
||||
{"_e":"metro:bundling:done","_t":1784296590410,"id":"d","total":1,"ms":1297.260922}
|
||||
{"_e":"metro:bundling:started","_t":1784296639397,"id":"e","platform":"android","environment":null,"entry":"index.ts"}
|
||||
{"_e":"metro:bundling:progress","_t":1784296639397,"id":"e","progress":0,"total":1,"current":0}
|
||||
{"_e":"metro:bundling:done","_t":1784296639495,"id":"e","total":1,"ms":97.436217}
|
||||
{"_e":"metro:bundling:started","_t":1784299195451,"id":"f","platform":"android","environment":null,"entry":"index.ts"}
|
||||
{"_e":"metro:bundling:progress","_t":1784299195476,"id":"f","progress":0,"total":1,"current":0}
|
||||
{"_e":"metro:bundling:progress","_t":1784299195479,"id":"f","progress":0,"total":1,"current":0}
|
||||
{"_e":"metro:bundling:done","_t":1784299196005,"id":"f","total":1,"ms":529.887212}
|
||||
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"devices": [
|
||||
{
|
||||
"installationId": "3ac39ffb-519e-4fcd-b686-c20d0a63c449",
|
||||
"lastUsed": 1784299195276
|
||||
}
|
||||
]
|
||||
}
|
||||
|
Before Width: | Height: | Size: 3.3 KiB |
|
Before Width: | Height: | Size: 5.8 KiB |
|
Before Width: | Height: | Size: 8.6 KiB |
|
Before Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 5.5 KiB |
|
Before Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 39 KiB |
|
Before Width: | Height: | Size: 66 KiB |
|
Before Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 2.1 KiB |
|
Before Width: | Height: | Size: 2.6 KiB |
|
Before Width: | Height: | Size: 4.9 KiB |
|
Before Width: | Height: | Size: 5.2 KiB |
|
Before Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 3.3 KiB |
|
Before Width: | Height: | Size: 6.4 KiB |
|
Before Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 29 KiB |
|
Before Width: | Height: | Size: 3.0 KiB |
|
Before Width: | Height: | Size: 5.7 KiB |
|
Before Width: | Height: | Size: 9.0 KiB |
|
Before Width: | Height: | Size: 1.4 KiB |
@@ -1 +1,5 @@
|
||||
node_modules
|
||||
.expo/
|
||||
modules/*/android/build/
|
||||
android/keystore.properties
|
||||
android/app/release.keystore
|
||||
|
||||
@@ -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".
|
||||
@@ -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,111 @@
|
||||
# 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).
|
||||
|
||||
## 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,72 +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.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"
|
||||
],
|
||||
"package": "com.locphamtran.recipescamera"
|
||||
},
|
||||
"web": {
|
||||
"favicon": "./assets/favicon.png"
|
||||
},
|
||||
"plugins": [
|
||||
"expo-asset",
|
||||
[
|
||||
"react-native-vision-camera",
|
||||
{
|
||||
"enableFrameProcessors": true,
|
||||
"cameraPermissionText": "Allow CamRecipe Pro to access your camera."
|
||||
}
|
||||
],
|
||||
[
|
||||
"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.",
|
||||
"isAccessMediaLocationEnabled": true
|
||||
}
|
||||
],
|
||||
[
|
||||
"expo-image-picker",
|
||||
{
|
||||
"photosPermission": "Allow CamRecipe Pro to select photos from your library to apply presets.",
|
||||
"microphonePermission": false
|
||||
}
|
||||
],
|
||||
"expo-sharing"
|
||||
],
|
||||
"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);
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
plugins {
|
||||
id 'com.android.library'
|
||||
id 'expo-module-gradle-plugin'
|
||||
}
|
||||
|
||||
group = 'com.locphamtran.recipescamera'
|
||||
version = '0.1.0'
|
||||
|
||||
android {
|
||||
namespace "com.locphamtran.recipescamera.export"
|
||||
defaultConfig {
|
||||
versionCode 1
|
||||
versionName "0.1.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"platforms": ["android"],
|
||||
"android": {
|
||||
"modules": [
|
||||
"com.locphamtran.recipescamera.export.RecipescamExportModule"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import { requireNativeModule } from 'expo-modules-core';
|
||||
|
||||
export interface DecodeEncodeTimings {
|
||||
decodeMs: number;
|
||||
encodeWriteMs: number;
|
||||
totalMs: number;
|
||||
}
|
||||
|
||||
export interface ColorTimings {
|
||||
decodeMs: number;
|
||||
toneMs?: number;
|
||||
cinemaMs?: number;
|
||||
colorMs: number;
|
||||
clarityMs?: number;
|
||||
grainMs?: number;
|
||||
frameMs?: number;
|
||||
sharpenMs?: number;
|
||||
encodeWriteMs: number;
|
||||
totalMs: number;
|
||||
}
|
||||
|
||||
/** Rect (pixel coords) of the center-largest aspect crop, computed in JS. */
|
||||
export interface CropRect {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
/** ToneUniforms from toneShader.ts (0/0/0 = skip tone pass). */
|
||||
export interface ToneUniforms {
|
||||
dr: number;
|
||||
hl: number;
|
||||
sh: number;
|
||||
}
|
||||
|
||||
/** Optional pipeline params passed as a map (native Coroutine arity cap = 8). */
|
||||
export interface PhotoAdjust {
|
||||
/** ToneUniforms from toneShader.ts; default 0/0/0 = skip tone pass. */
|
||||
toneDr?: number;
|
||||
toneHl?: number;
|
||||
toneSh?: number;
|
||||
/** 16 flat floats from cinemaShader.ts getCinemaUniforms(season).flat; null/omitted or flat[0]=0 = skip. */
|
||||
cinema?: number[] | null;
|
||||
/** ColorAdjustments.denoise -10..10; >0 blur, <0 re-grain (0 = skip). */
|
||||
denoise?: number;
|
||||
/** ColorAdjustments.clarity -10..10; >0 conv sharpen, <0 mist blur (0 = skip). */
|
||||
clarity?: number;
|
||||
/** ColorAdjustments.grain 0..10 (0 = skip). */
|
||||
grain?: number;
|
||||
/** JPEG quality 0..100; default 95. */
|
||||
quality?: number;
|
||||
/** Final screen-sharpen amount (exportEngine #9 screenSharpenImage); >0 applies. */
|
||||
sharpen?: number;
|
||||
/** Soften blur radius for a negative sharpening knob (exportEngine #4); >0 applies. */
|
||||
soften?: number;
|
||||
/** DPI stamped into the JPEG header after encode (patchJpegDpi port); 0 = skip. */
|
||||
dpi?: number;
|
||||
/** Composition aspect-ratio crop computed after decode (center-largest, engine #1b). */
|
||||
aspect?: '4:3' | '3:2' | 'full';
|
||||
/** Photo frame preset applied before encode, mirroring frameUtils.ts. Default 'none'. */
|
||||
frameId?: 'none' | 'classic-white' | 'cinematic' | 'polaroid' | 'wallframe';
|
||||
/** Base64 PNG art (wallframe only, 4000x3117). Decoded per call. */
|
||||
frameArtB64?: string;
|
||||
/** Base64 TTF body font (geo stamp only). Cached after first decode. */
|
||||
fontB64?: string;
|
||||
/** Base64 TTF emoji font (geo stamp only). Cached after first decode. */
|
||||
iconB64?: string;
|
||||
/** Optional GPS stamp {locality, timestamp}; null/omitted = skip. */
|
||||
geo?: { locality: string; timestamp: number } | null;
|
||||
/** Encode PNG instead of JPEG. Probe-only. */
|
||||
png?: boolean;
|
||||
/** Probe-only: sample these photo-space pixel coords after frame pass, before encode; logs via samplePixel. */
|
||||
probeSamples?: Array<{ x: number; y: number }>;
|
||||
/** Probe-only: log per-row stamp diff/amber/white metrics (see samplePixel) and return them in ColorTimings-adjacent logs. */
|
||||
probeStampDiff?: boolean;
|
||||
}
|
||||
|
||||
export interface RecipescamExportModule {
|
||||
/** Decode JPEG/PNG at srcPath (plain absolute path), re-encode JPEG q and write dstPath. All native, background. Returns stage timings in ms. */
|
||||
decodeEncodeAsync(srcPath: string, dstPath: string, quality: number): Promise<DecodeEncodeTimings>;
|
||||
/**
|
||||
* Native pipeline mirroring exportEngine.ts filter order — decode, optional
|
||||
* center-largest crop, optional tone pass (TONE_SKSL port), optional cinema
|
||||
* seasonal grade (CINEMA_SKSL port), row-major 4x5 color matrix (translate
|
||||
* column in 0..1 Skia space), optional denoise blur / clarity conv / mist
|
||||
* blur / grain Overlay, then JPEG-encode to dstPath. All native, off the
|
||||
* JS/main threads. Stage order mirrors exportEngine: crop -> tone -> cinema
|
||||
* -> matrix -> denoise/clarity -> grain -> frame (classic/cinematic/
|
||||
* polaroid/wallframe) -> GPS stamp (geo, bottom of the composed output)
|
||||
* -> encode. Timings gain frameMs when a frame/stamp pass runs.
|
||||
*/
|
||||
processPhotoAsync(srcPath: string, dstPath: string, matrix: number[], crop: CropRect | null, adjust?: PhotoAdjust): Promise<ColorTimings>;
|
||||
/** P1 alias: matrix + crop, no tone/cinema/enhance passes. */
|
||||
processColorAsync(srcPath: string, dstPath: string, matrix: number[], crop: CropRect | null, quality: number): Promise<ColorTimings>;
|
||||
/**
|
||||
* Insert an encoded image into DCIM/Camera (MediaStore RELATIVE_PATH) — where
|
||||
* the stock camera writes. Returns the MediaStore _ID (expo Asset.id shape),
|
||||
* or '' when the insert/copy fails, so JS falls back to MediaLibrary.
|
||||
*/
|
||||
saveToCameraRollAsync(srcPath: string, mimeType: string): Promise<string>;
|
||||
/**
|
||||
* Raw device identity the stock Xiaomi camera folds into EXIF 0x889e (the
|
||||
* watermark blob HyperOS Gallery reads) but CameraX/HAL never supplies:
|
||||
* codename, marketing name, manufacturer, model. Empty strings when absent.
|
||||
*/
|
||||
deviceInfo(): { device: string; marketName: string; manufacturer: string; model: string };
|
||||
|
||||
/**
|
||||
* The image a system share handed this launch (AndroidManifest
|
||||
* ACTION_SEND — see takeSharedImage in the Kotlin module): the sender's
|
||||
* stream is copied into the cache and returned as an ordinary file.
|
||||
* `path` is "" when this launch was not a share, or when the share was
|
||||
* already taken — the intent is read and cleared exactly once.
|
||||
*/
|
||||
takeSharedImage(): Promise<{ path: string; assetId: string; type: string }>;
|
||||
|
||||
/** P0 probe helpers — removed before ship. */
|
||||
ping(): Promise<string>;
|
||||
materializeBenchAsset(assetName: string): Promise<string>;
|
||||
materializeSolidAsset(r: number, g: number, b: number): Promise<string>;
|
||||
materializeRampAsset(): Promise<string>;
|
||||
materializeStepAsset(): Promise<string>;
|
||||
}
|
||||
|
||||
export default requireNativeModule('RecipesCamExport') as RecipescamExportModule;
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"name": "recipescam-export",
|
||||
"version": "0.1.0",
|
||||
"description": "Native Kotlin photo export pipeline (decode/process/encode off the JS thread) for Recipescam.",
|
||||
"main": "index.ts",
|
||||
"license": "MIT",
|
||||
"private": true
|
||||
}
|
||||