Compare commits
253 Commits
fixbug-01
...
e021f7d1ff
| Author | SHA1 | Date | |
|---|---|---|---|
| e021f7d1ff | |||
| 56d4b9df67 | |||
| cd9dd28adc | |||
| 3f5d2cd017 | |||
| 81ed53cf78 | |||
| d37671c359 | |||
| 0e9f78bd5e | |||
| 64f1598076 | |||
| c2a4740a3f | |||
| 4795a2a0ee | |||
| 7a8c0aa2b5 | |||
| d55b7b49ca | |||
| 438788e031 | |||
| 3c8a1acc69 | |||
| e8a0c0d076 | |||
| cf0091b741 | |||
| 3bfa82e7f2 | |||
| a0965101e1 | |||
| a2c12a2638 | |||
| 28a688fd82 | |||
| 52566f6966 | |||
| a5d195c5d2 | |||
| d9bababfd7 | |||
| 3a09674831 | |||
| 500068e63e | |||
| b9ac7746aa | |||
| f201deee46 | |||
| 1d4c6b1d66 | |||
| 3d273a3188 | |||
| c403dd04c8 | |||
| 80512d11b5 | |||
| 2ee49cfd41 | |||
| e1f6943f8e | |||
| bb51b83399 | |||
| cfe8512636 | |||
| 7f2a5b0b5a | |||
| 339d36eb3e | |||
| 7dea0f1e7b | |||
| 88afdf6611 | |||
| 1d96269139 | |||
| b394bad09e | |||
| 711e2fc5d6 | |||
| 2f78216b6f | |||
| c0c99a9672 | |||
| f9a40a9e1c | |||
| 59d90ae068 | |||
| 428e7fa682 | |||
| 52b672deec | |||
| efe578f61c | |||
| 6afb7d9fea | |||
| 15bacacafa | |||
| e57444e88b | |||
| 2da045f232 | |||
| d3c5c47973 | |||
| d25649a26b | |||
| f263413313 | |||
| 07fbadcdc5 | |||
| 4057566a14 | |||
| 27035c4acb | |||
| 10466e122a | |||
| 1b71c0196f | |||
| 8a889db069 | |||
| a35ecf4f1c | |||
| 260517c547 | |||
| ee2c402438 | |||
| 4d0d44170f | |||
| d7d7be2c6b | |||
| abaa980f93 | |||
| 78fe2b097b | |||
| 97836fde8a | |||
| dcea6b926f | |||
| 72d5ce1c3e | |||
| 27099c2877 | |||
| 8308b3e65f | |||
| 89beb5f160 | |||
| 8a7192eae8 | |||
| 1c4cdf8af1 | |||
| 0627f8dd91 | |||
| cf4b01d4b3 | |||
| f17cb08a63 | |||
| 567f650bbb | |||
| 0f52572f20 | |||
| 8259bd468d | |||
| e437946ee0 | |||
| 86a93f46be | |||
| b4d5d2926b | |||
| 43d86b4b6f | |||
| 8e6c1493e8 | |||
| 40177cb63f | |||
| 6bbf77860b | |||
| 2917c034ed | |||
| 7b79e49c20 | |||
| bd2f08aaf7 | |||
| f13fc37b5a | |||
| ffdefd2c9c | |||
| 9ba2667c6c | |||
| f1174b8c57 | |||
| 3af5a315e2 | |||
| 99be7e96bd | |||
| 8747b1686b | |||
| 014b3e5a4b | |||
| 6e8a600a82 | |||
| 751beb52c1 | |||
| cc313f2ea9 | |||
| cc88471256 | |||
| 51e572b9ce | |||
| 1446e852b1 | |||
| a684b178b6 | |||
| d31d945827 | |||
| f2e5e9abc9 | |||
| 340f0374fc | |||
| dace19aec4 | |||
| c00c605dd7 | |||
| 8c6e7930db | |||
| 608901bd0f | |||
| 73c063a6ac | |||
| 8b4c4aa591 | |||
| cef3ddb307 | |||
| 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,865 @@
|
||||
# Port HSL (selective colour mixer) — `docker/frontend` → Android
|
||||
|
||||
Tài liệu này mô tả **chính xác** cách đưa tính năng HSL của bản web (`docker/frontend/`) sang app Android (Expo + Skia, source ở repo root).
|
||||
|
||||
Web là **bản tham chiếu**: `docker/frontend/shared/` là bản copy của `src/` bên Android nhưng đã đi trước. Hướng port là:
|
||||
|
||||
```
|
||||
docker/frontend/shared/** → src/**
|
||||
docker/frontend/src/** → src/components/**, App.tsx
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 0. Phạm vi — chỉ HSL
|
||||
|
||||
Diff giữa `src/` và `docker/frontend/shared/` **không chỉ có HSL**. Các delta sau nằm cùng file nhưng **KHÔNG thuộc tài liệu này**, tuyệt đối không port kèm:
|
||||
|
||||
| Delta web-only | Nơi xuất hiện | Kết luận |
|
||||
|---|---|---|
|
||||
| WB white/black point (`whites`/`blacks`) | `types`, `paramDefs`, `toneShader` (`wh`/`bl`), `defaultRecipes` | **BỎ** |
|
||||
| Base filter `classic-vivid` (`sim-classic-vivid`) | `types`, `toneShader` `FILM_TONE`, `defaultRecipes` | **BỎ** |
|
||||
| Frame `wallframe-landscape` | `types` `FrameId`, `recipeShare` `FRAMES`, chips ROTATE/wall | **BỎ** |
|
||||
| `GLOW_T0/GLOW_T1` 0.55/0.85 → 0.45/0.75 | `toneShader` | **BỎ** — đây là chỉnh demo của web; Android giữ `0.55/0.85` |
|
||||
| `exifWrite.ts` diff (22 dòng) | — | không liên quan HSL |
|
||||
|
||||
Chỉ port: **types HSL**, **`colorUtils` HSL**, **`toneShader` mixer**, **`recipeShare` sanitize**, **`defaultRecipes` 3 field toàn ảnh**, **UI tab HSL**.
|
||||
|
||||
---
|
||||
|
||||
## 1. Ngữ nghĩa (đọc trước khi code)
|
||||
|
||||
Mixer chọn màu theo **8 dải hue**. Mỗi dải có 3 knob `[hue, sat, lum]`, mỗi knob `-10..+10`:
|
||||
|
||||
- **hue** — xoay hue, ±30° khi full (`acc.x * 30/360`).
|
||||
- **sat** — scale saturation, `-10` = xám (`s * (1 + acc.y)`, `acc.y = -1`).
|
||||
- **lum** — cộng lightness, ±0.25 khi full (`acc.z * 0.25`), additive nên không đảo ramp.
|
||||
|
||||
Ba knob **toàn ảnh** (`hslHue`, `hslSat`, `hslLum`) là "seed" của accumulator: mọi hue nhận full weight.
|
||||
|
||||
Quy tắc bất biến — port phải giữ đúng:
|
||||
|
||||
1. **Partition of unity.** Weight mỗi dải là tent tuyến tính: full tại anchor của nó, về 0 tại anchor hai dải kề. Hai tent kề cắt nhau đúng tại 0.5 ở midpoint ⇒ 8 weight **cộng lại bằng 1 tại mọi hue**. Không pixel nào bị tính hai lần, không hue nào rơi vào khe.
|
||||
2. **Anchor cố định 1 nguồn.** `HSL_BANDS` (cho chip) và `BAND_BLOCK` (cho shader) sinh từ cùng bảng qua `hslBandGaps()` ⇒ anchor không thể lệch.
|
||||
3. **Gate xám.** `gate = smoothstep(0.0, 0.08, hsl.y)` — pixel có saturation < 8% bị loại khỏi weight, vì `rgb2hsl` trả hue 0 cho xám và nếu không gate thì **mọi pixel trung tính sẽ chạy theo dải RED**.
|
||||
4. **`gl` KHÔNG gate.** Lightness toàn ảnh (`gl`) được cộng **sau** gate, nên ảnh đã bị `-SAT` rút về xám vẫn còn `+LUM`.
|
||||
5. **Monochrome tắt mixer.** `if (!colour) hslOn = 0;` — stock đen trắng không có hue để chọn.
|
||||
6. **Dải toàn 0 bị xoá.** Band `[0,0,0]` không bao giờ vào recipe; kéo knob về 0 thì dải biến mất không để lại dấu.
|
||||
7. **Mixer chạy CUỐI**, sau split-tone/kv và sau vibrance — để band edit được đánh giá trên màu người dùng thực sự sample.
|
||||
|
||||
---
|
||||
|
||||
## 2. `src/types/index.ts`
|
||||
|
||||
Thêm 2 type (đặt cạnh `BaseFilter`/`ColorAdjustments`):
|
||||
|
||||
```ts
|
||||
export type HslBandId = 'red' | 'orange' | 'yellow' | 'green' | 'aqua' | 'blue' | 'purple' | 'magenta';
|
||||
// [hue, sat, lum], mỗi số -10..+10; dải toàn 0 bị xoá khỏi recipe
|
||||
export type HslBand = [number, number, number];
|
||||
```
|
||||
|
||||
Trong `interface ColorAdjustments` (sau `vibrance?`, trước `exposureCompensation`):
|
||||
|
||||
```ts
|
||||
hslBands?: Partial<Record<HslBandId, HslBand>>; // selective colour: mỗi dải [hue, sat, lum], -10..+10
|
||||
hslHue?: number; // -10..+10 xoay hue toàn ảnh
|
||||
hslSat?: number; // -10..+10 scale saturation toàn ảnh
|
||||
hslLum?: number; // -10..+10 cộng lightness toàn ảnh
|
||||
```
|
||||
|
||||
> `hslBands` là **một field lồng**, không phải 24 field phẳng — đây là lý do nó cần gate riêng khi import (mục 5).
|
||||
|
||||
---
|
||||
|
||||
## 3. `src/utils/colorUtils.ts`
|
||||
|
||||
File Android hiện chỉ có `kelvinToRGB`, `getSkiaColorMatrix`, `applyExposureGain`. Thêm nguyên khối sau **lên đầu file** (sau import):
|
||||
|
||||
```ts
|
||||
import { ColorAdjustments, BaseFilter, HslBand, HslBandId } from '../types';
|
||||
|
||||
// 8 dải của mixer chọn màu, và anchor hue mỗi dải đứng (độ trên vòng HSL).
|
||||
// Anchor cố tình không đều: red/orange/yellow cách nhau 30° vì mắt phân biệt
|
||||
// kỹ vùng này, còn phía blue là một khúc 60°. TONE_SKSL dựng đúng 8 weight này
|
||||
// từ bảng dưới, nên anchor chip sửa và anchor shader mask không thể lệch.
|
||||
//
|
||||
// `label` là chữ chip in ra — chuỗi của app, KHÔNG nằm trong i18n.
|
||||
export const HSL_BANDS: { id: HslBandId; hue: number; label: string }[] = [
|
||||
{ id: 'red', hue: 0, label: 'RED' },
|
||||
{ id: 'orange', hue: 30, label: 'ORANGE' },
|
||||
{ id: 'yellow', hue: 60, label: 'YELLOW' },
|
||||
{ id: 'green', hue: 120, label: 'GREEN' },
|
||||
{ id: 'aqua', hue: 180, label: 'AQUA' },
|
||||
{ id: 'blue', hue: 240, label: 'BLUE' },
|
||||
{ id: 'purple', hue: 280, label: 'PURPLE' },
|
||||
{ id: 'magenta', hue: 320, label: 'MAGENTA' },
|
||||
];
|
||||
|
||||
// Hue (độ) của hai dải kề mỗi dải, đúng như TONE_SKSL cần. Suy ra từ HSL_BANDS
|
||||
// để hai bảng không thể cãi nhau: tent weight của một dải về 0 tại anchor của
|
||||
// hai dải kề, khiến 8 weight là partition of unity trên vòng hue (cộng = 1 tại
|
||||
// mọi hue).
|
||||
export function hslBandGaps(): { id: HslBandId; hue: number; left: number; right: number }[] {
|
||||
const n = HSL_BANDS.length;
|
||||
return HSL_BANDS.map((b, i) => {
|
||||
const prev = HSL_BANDS[(i - 1 + n) % n].hue;
|
||||
const next = HSL_BANDS[(i + 1) % n].hue;
|
||||
// Red (0°) wrap: dải kề trái là magenta 320°, tức 40° về sau.
|
||||
return { id: b.id, hue: b.hue, left: (b.hue - prev + 360) % 360, right: (next - b.hue + 360) % 360 };
|
||||
});
|
||||
}
|
||||
|
||||
// 0..255 sRGB → HSL. Hue theo độ, saturation/lightness 0..1 — chiều ngược của
|
||||
// hsl2rgb trong shader, và là thứ readout của eyedropper in ra.
|
||||
export function rgbToHsl(r: number, g: number, b: number): { h: number; s: number; l: number } {
|
||||
const R = r / 255;
|
||||
const G = g / 255;
|
||||
const B = b / 255;
|
||||
const mx = Math.max(R, G, B);
|
||||
const mn = Math.min(R, G, B);
|
||||
const l = (mx + mn) / 2;
|
||||
const d = mx - mn;
|
||||
if (d < 1e-6) return { h: 0, s: 0, l };
|
||||
const s = l > 0.5 ? d / (2 - mx - mn) : d / (mx + mn);
|
||||
let h: number;
|
||||
if (mx === R) h = ((G - B) / d + (G < B ? 6 : 0)) * 60;
|
||||
else if (mx === G) h = ((B - R) / d + 2) * 60;
|
||||
else h = ((R - G) / d + 4) * 60;
|
||||
return { h, s, l };
|
||||
}
|
||||
|
||||
// Dải mà một hue sample thuộc về: anchor gần nhất. Các dải chồng nhau trong
|
||||
// shader, nên hàm này chỉ quyết định ruler đang trỏ vào dải nào.
|
||||
export function nearestHslBand(hue: number): HslBandId {
|
||||
const wrapped = ((hue % 360) + 360) % 360;
|
||||
let best = HSL_BANDS[0];
|
||||
let bestD = 361;
|
||||
for (const band of HSL_BANDS) {
|
||||
const d = Math.abs(((wrapped - band.hue + 540) % 360) - 180);
|
||||
if (d < bestD) {
|
||||
bestD = d;
|
||||
best = band;
|
||||
}
|
||||
}
|
||||
return best.id;
|
||||
}
|
||||
|
||||
// Một band triple như khi lưu: số nguyên -10..10, dải toàn 0 bị xoá để recipe
|
||||
// chỉ mang theo thứ người dùng thực sự đổi.
|
||||
const clamp10 = (v: unknown): number =>
|
||||
typeof v === 'number' && Number.isFinite(v) ? Math.max(-10, Math.min(10, Math.round(v))) : 0;
|
||||
|
||||
export function sanitizeHslBands(raw: unknown): Partial<Record<HslBandId, HslBand>> | undefined {
|
||||
if (!raw || typeof raw !== 'object') return undefined;
|
||||
const out: Partial<Record<HslBandId, HslBand>> = {};
|
||||
for (const band of HSL_BANDS) {
|
||||
const v = (raw as Record<string, unknown>)[band.id];
|
||||
if (!Array.isArray(v) || v.length !== 3) continue;
|
||||
const triple: HslBand = [clamp10(v[0]), clamp10(v[1]), clamp10(v[2])];
|
||||
if (triple[0] || triple[1] || triple[2]) out[band.id] = triple;
|
||||
}
|
||||
return Object.keys(out).length ? out : undefined;
|
||||
}
|
||||
```
|
||||
|
||||
Không sửa `getSkiaColorMatrix`: mixer nằm ở tone shader, matrix 4×5 không làm được per-pixel hue.
|
||||
|
||||
---
|
||||
|
||||
## 4. `src/utils/toneShader.ts` — shader mixer
|
||||
|
||||
Đây là phần lõi. Sáu sửa đổi, theo thứ tự trong file.
|
||||
|
||||
### 4.1 Import + `BAND_BLOCK`
|
||||
|
||||
```ts
|
||||
import { BaseFilter, ColorAdjustments } from '../types';
|
||||
import { HSL_BANDS, hslBandGaps } from './colorUtils';
|
||||
```
|
||||
|
||||
Thêm **trước** `export const TONE_SKSL`:
|
||||
|
||||
```ts
|
||||
// Tám dòng band của mixer trong TONE_SKSL, sinh từ HSL_BANDS để anchor và gap
|
||||
// trong shader chính là số mà chip dựng ra. Mỗi dòng đọc 3 giá trị của dải nó
|
||||
// với index HẰNG — SkSL chỉ index uniform array bằng hằng, nên block này phải
|
||||
// unroll chứ không loop được.
|
||||
const BAND_BLOCK = hslBandGaps()
|
||||
.map(
|
||||
(b, i) => ` float w${i} = bandW(hd, ${b.hue.toFixed(1)}, ${b.left.toFixed(1)}, ${b.right.toFixed(1)}) * gate;
|
||||
acc += vec3(w${i} * hslH[${i}], w${i} * hslS[${i}], w${i} * hslL[${i}]);\n`
|
||||
)
|
||||
.join('');
|
||||
```
|
||||
|
||||
### 4.2 Comment header
|
||||
|
||||
Thêm vào khối comment đầu file (cạnh `vib`):
|
||||
|
||||
```
|
||||
// hslOn/hslH/hslS/hslL - mixer chọn màu: 8 dải hue, mỗi dải một hue shift, một
|
||||
// saturation scale và một lightness offset (-1..1, từ knob -10..10). Dải
|
||||
// nào sở hữu pixel nào được quyết định Ở ĐÂY, per-pixel theo hue — nên
|
||||
// khác mọi thứ phía trên, 8 dải không phải một phép move toàn cục và
|
||||
// không thể nằm trong colour matrix. Xem band block ở cuối TONE_SKSL.
|
||||
// gh/gs/gl - move toàn ảnh của mixer: đúng ba đại lượng đó cho TOÀN ảnh, nên
|
||||
// chúng chỉ là giá trị khởi tạo của accumulator và mọi hue nhận full
|
||||
// weight. Lightness KHÔNG bị gate theo saturation (khác band), nên ảnh bị
|
||||
// -SAT rút về xám vẫn trả lời +LUM.
|
||||
```
|
||||
|
||||
### 4.3 Uniform khai báo trong `TONE_SKSL`
|
||||
|
||||
Thêm **sau `uniform float ccb;`** (Android không có `wh`/`bl`, nên HSL nối thẳng sau `ccb`):
|
||||
|
||||
```glsl
|
||||
uniform float hslOn;
|
||||
uniform float hslH[8];
|
||||
uniform float hslS[8];
|
||||
uniform float hslL[8];
|
||||
uniform float gh;
|
||||
uniform float gs;
|
||||
uniform float gl;
|
||||
```
|
||||
|
||||
Rồi **ngay trước `vec4 main(vec2 xy)`**, thêm 4 helper + mixer vào chuỗi SkSL:
|
||||
|
||||
```glsl
|
||||
// sRGB <-> HSL. Mixer làm việc trong HSL vì đó là không gian knob được đặt tên
|
||||
// theo: một hue shift không được đổi độ sáng của màu, và một lightness move
|
||||
// không được đổi hue — đúng thứ mà scale RGB làm sai.
|
||||
vec3 rgb2hsl(vec3 c) {
|
||||
float mx = max(max(c.r, c.g), c.b);
|
||||
float mn = min(min(c.r, c.g), c.b);
|
||||
float l = (mx + mn) * 0.5;
|
||||
float d = mx - mn;
|
||||
if (d < 0.00001) return vec3(0.0, 0.0, l);
|
||||
float s = l > 0.5 ? d / max(0.00001, 2.0 - mx - mn) : d / max(0.00001, mx + mn);
|
||||
float h;
|
||||
if (mx == c.r) h = (c.g - c.b) / d + (c.g < c.b ? 6.0 : 0.0);
|
||||
else if (mx == c.g) h = (c.b - c.r) / d + 2.0;
|
||||
else h = (c.r - c.g) / d + 4.0;
|
||||
return vec3(h / 6.0, s, l);
|
||||
}
|
||||
float hueChannel(float p, float q, float t) {
|
||||
t = fract(t);
|
||||
if (t < 1.0 / 6.0) return p + (q - p) * 6.0 * t;
|
||||
if (t < 0.5) return q;
|
||||
if (t < 2.0 / 3.0) return p + (q - p) * (2.0 / 3.0 - t) * 6.0;
|
||||
return p;
|
||||
}
|
||||
vec3 hsl2rgb(vec3 hsl) {
|
||||
if (hsl.y < 0.00001) return vec3(hsl.z);
|
||||
float q = hsl.z < 0.5 ? hsl.z * (1.0 + hsl.y) : hsl.z + hsl.y - hsl.z * hsl.y;
|
||||
float p = 2.0 * hsl.z - q;
|
||||
return vec3(
|
||||
hueChannel(p, q, hsl.x + 1.0 / 3.0),
|
||||
hueChannel(p, q, hsl.x),
|
||||
hueChannel(p, q, hsl.x - 1.0 / 3.0)
|
||||
);
|
||||
}
|
||||
// Mức sở hữu một hue của một dải: full tại anchor của dải, giảm tuyến tính về 0
|
||||
// tại anchor hai dải kề (gap không đều — red cách orange 30° và cách magenta
|
||||
// 40°). Tính tuyến tính là mấu chốt: hai tent kề cắt nhau đúng 0.5 tại midpoint,
|
||||
// nên 8 weight cộng lại bằng 1 tại mọi hue. Không pixel nào bị tính hai lần,
|
||||
// không pixel nào rơi vào khe, và hue nằm đúng anchor nhận full giá trị của dải
|
||||
// đó thay vì một phần.
|
||||
float bandW(float hue, float anchor, float gapL, float gapR) {
|
||||
float d = mod(hue - anchor + 180.0, 360.0) - 180.0;
|
||||
return d <= 0.0 ? max(0.0, 1.0 + d / gapL) : max(0.0, 1.0 - d / gapR);
|
||||
}
|
||||
```
|
||||
|
||||
### 4.4 Mixer trong `main()` — thay đoạn kết
|
||||
|
||||
Trong `main`, đoạn cuối Android hiện là:
|
||||
|
||||
```glsl
|
||||
float kv = 1.0 + vib * 0.75 * (1.0 - chroma);
|
||||
return vec4(clamp(mix(vec3(l2), rgb, kv), 0.0, 1.0), c.a);
|
||||
}
|
||||
```
|
||||
|
||||
Đổi thành:
|
||||
|
||||
```glsl
|
||||
float kv = 1.0 + vib * 0.75 * (1.0 - chroma);
|
||||
rgb = clamp(mix(vec3(l2), rgb, kv), 0.0, 1.0);
|
||||
// Selective colour theo dải hue — move CUỐI, để một band edit được đánh giá
|
||||
// trên đúng màu người dùng sample từ render.
|
||||
//
|
||||
// Pixel xám bị loại trước khi đọc weight: rgb2hsl trả hue 0 cho nó, nên nếu
|
||||
// không gate thì MỌI pixel trung tính trong khung sẽ bị coi là đỏ nguyên chất
|
||||
// và trôi theo dải red. Dưới 8% saturation thì cũng không có hue để move.
|
||||
//
|
||||
// Ba accumulator là giá trị band nhân mức sở hữu, nên một hue nằm giữa hai
|
||||
// anchor nhận hỗn hợp tỉ lệ của hai edit — đúng blend mà weight đã cộng ra.
|
||||
// Hue là phép xoay (±30° khi full), saturation là scale (0 = xám tại -10),
|
||||
// lightness là additive (±0.25 khi full) nên không thể đảo ramp.
|
||||
if (hslOn > 0.5) {
|
||||
vec3 hsl = rgb2hsl(rgb);
|
||||
float gate = smoothstep(0.0, 0.08, hsl.y);
|
||||
float hd = hsl.x * 360.0;
|
||||
// Move toàn ảnh là seed: mọi hue nhận hue turn và saturation scale ở full
|
||||
// weight, các dải cộng thêm phần của mình lên trên. Lightness được cộng
|
||||
// bên dưới KHÔNG gate, nên nó vẫn nâng một màu đã bị -SAT rút về xám.
|
||||
vec3 acc = vec3(gh, gs, 0.0) * gate;
|
||||
${BAND_BLOCK} hsl.x = fract(hsl.x + acc.x * (30.0 / 360.0));
|
||||
hsl.y = clamp(hsl.y * (1.0 + acc.y), 0.0, 1.0);
|
||||
hsl.z = clamp(hsl.z + (acc.z + gl) * 0.25, 0.0, 1.0);
|
||||
rgb = hsl2rgb(hsl);
|
||||
}
|
||||
return vec4(clamp(rgb, 0.0, 1.0), c.a);
|
||||
}
|
||||
```
|
||||
|
||||
> `${BAND_BLOCK}` phải nằm trong template literal — 8 dòng sinh ra nối liền ngay trước `hsl.x =`. Giữ đúng dạng đó, đừng thụt lề lại: SkSL không quan tâm, nhưng giữ khớp bản web để diff sau này đọc được.
|
||||
|
||||
### 4.5 `ToneUniforms`
|
||||
|
||||
Thêm vào interface (sau `ccb`):
|
||||
|
||||
```ts
|
||||
hslOn: number; // 1 khi có band hoặc move toàn ảnh được set (0 = skip mixer)
|
||||
hslH: number[]; // 8 × -1..1 theo dải, thứ tự HSL_BANDS (±30° hue khi full)
|
||||
hslS: number[]; // 8 × -1..1 (saturation scale, -1 = xám)
|
||||
hslL: number[]; // 8 × -1..1 (lightness additive, ±0.25 khi full)
|
||||
gh: number; // -1..1 xoay hue toàn ảnh (±30° khi full)
|
||||
gs: number; // -1..1 scale saturation toàn ảnh
|
||||
gl: number; // -1..1 cộng lightness toàn ảnh (±0.25 khi full, ungated)
|
||||
```
|
||||
|
||||
### 4.6 `getToneUniforms`
|
||||
|
||||
Trong thân hàm, **trước `return {`**:
|
||||
|
||||
```ts
|
||||
// Selective colour: một slot mỗi dải, theo thứ tự HSL_BANDS, để flat buffer
|
||||
// khớp với array của shader. Dải người dùng chưa move giữ ba số 0 và chỉ tốn
|
||||
// slot của nó.
|
||||
const bands = adj.hslBands ?? {};
|
||||
const tenth = (v: unknown) =>
|
||||
typeof v === 'number' && Number.isFinite(v) ? Math.max(-1, Math.min(1, v / 10)) : 0;
|
||||
const hslH: number[] = [];
|
||||
const hslS: number[] = [];
|
||||
const hslL: number[] = [];
|
||||
let hslOn = 0;
|
||||
for (const band of HSL_BANDS) {
|
||||
const v = bands[band.id];
|
||||
const [h, s, l] = v ? [tenth(v[0]), tenth(v[1]), tenth(v[2])] : [0, 0, 0];
|
||||
hslH.push(h);
|
||||
hslS.push(s);
|
||||
hslL.push(l);
|
||||
if (h || s || l) hslOn = 1;
|
||||
}
|
||||
// Stock monochrome không có hue để chọn theo.
|
||||
if (!colour) hslOn = 0;
|
||||
// Move toàn ảnh của mixer, mọi hue nhận full weight.
|
||||
const gh = tenth(adj.hslHue);
|
||||
const gs = tenth(adj.hslSat);
|
||||
const gl = tenth(adj.hslLum);
|
||||
if (colour && (gh || gs || gl)) hslOn = 1;
|
||||
```
|
||||
|
||||
Và thêm vào object return (cuối, sau `ccb`):
|
||||
|
||||
```ts
|
||||
hslOn,
|
||||
hslH,
|
||||
hslS,
|
||||
hslL,
|
||||
gh,
|
||||
gs,
|
||||
gl,
|
||||
```
|
||||
|
||||
> `colour` đã có sẵn trong hàm (`const colour = baseFilter !== 'monochrome'`). Đặt khối HSL **sau** dòng đó.
|
||||
|
||||
### 4.7 `toneUniformArray`
|
||||
|
||||
```ts
|
||||
export function toneUniformArray(u: ToneUniforms): number[] {
|
||||
return [
|
||||
u.dr, u.hl, u.sh, u.vib,
|
||||
u.shT[0], u.shT[1], u.shT[2], u.hlT[0], u.hlT[1], u.hlT[2], u.cc, u.ccb,
|
||||
u.hslOn, ...u.hslH, ...u.hslS, ...u.hslL, u.gh, u.gs, u.gl,
|
||||
];
|
||||
}
|
||||
```
|
||||
|
||||
Tổng độ dài: `12 + 1 + 8 + 8 + 8 + 3 = 40`.
|
||||
|
||||
### 4.8 `toneIsActive`
|
||||
|
||||
`u.hslOn !== 0 ||` là **clause đầu tiên**:
|
||||
|
||||
```ts
|
||||
export function toneIsActive(u: ToneUniforms): boolean {
|
||||
return (
|
||||
u.hslOn !== 0 ||
|
||||
u.dr !== 0 ||
|
||||
... // phần còn lại giữ nguyên
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. `src/utils/recipeShare.ts`
|
||||
|
||||
Import:
|
||||
|
||||
```ts
|
||||
import { sanitizeHslBands } from './colorUtils';
|
||||
```
|
||||
|
||||
Trong `importRecipeXml`, **trước** dòng `const frameId: FrameId = ...`:
|
||||
|
||||
```ts
|
||||
// Mixer là adjustment duy nhất đến dưới dạng object lồng, nên là cái duy nhất
|
||||
// cần gate riêng: band id lạ bị bỏ, mọi giá trị bị kẹp về range knob, và một
|
||||
// dải để nguyên 0 bị xoá thay vì mang theo vô ích.
|
||||
adjustments.hslBands = sanitizeHslBands(raw.adjustments.hslBands);
|
||||
```
|
||||
|
||||
`exportRecipeXml` **không cần sửa**: `adjustments` được nhân bản nguyên khối vào payload JSON, `hslBands` tự đi theo. Format `.recipe` (XML bọc hex xorshift) giữ nguyên `version="1"`.
|
||||
|
||||
> `storageUtils.getAllRecipes` merge `{ ...DEFAULT_ADJUSTMENTS, ...r.adjustments }` — đủ cho 3 field toàn ảnh. `hslBands` từ storage là do chính app ghi nên không cần sanitize; nếu muốn chắc, gọi thêm `sanitizeHslBands` trong `fill`.
|
||||
|
||||
---
|
||||
|
||||
## 6. `src/utils/defaultRecipes.ts`
|
||||
|
||||
Thêm 3 field vào `DEFAULT_ADJUSTMENTS` (sau `vibrance` nếu có, trước `exposureCompensation`):
|
||||
|
||||
```ts
|
||||
hslHue: 0,
|
||||
hslSat: 0,
|
||||
hslLum: 0,
|
||||
```
|
||||
|
||||
Không thêm `hslBands` vào đây — nó optional và `undefined` mới là "không có dải nào"; thêm `hslBands: {}` sẽ làm `differs()` trong `App.tsx` báo dirty sai.
|
||||
|
||||
---
|
||||
|
||||
## 7. Uniform buffer — các điểm Android phải sửa thêm
|
||||
|
||||
`exportEngine.ts` **không cần sửa**: nó gọi `toneUniformArray(tone)` + `toneIsActive(tone)`, cả hai đã tự mang HSL.
|
||||
|
||||
`nativeExport.ts` (dev probe, `EXPO_PUBLIC_NATIVE_EXPORT`) **không mang HSL** — giống `colorChrome`, nó chỉ truyền `toneDr/toneHl/toneSh` vào `PhotoAdjust`. Ghi rõ trong comment cạnh comment "port them into applyTone()" hiện có; không cần làm gì thêm cho tới khi native path thành đường chính.
|
||||
|
||||
### 7.1 `src/components/Viewfinder.tsx` — BẮT BUỘC, dễ sót
|
||||
|
||||
Camera worklet đọc flat buffer qua `toneSync`, có hai chỗ hardcode theo độ dài cũ:
|
||||
|
||||
```tsx
|
||||
// dòng ~499
|
||||
const toneSync = useMemo(
|
||||
() => createSynchronizable<number[]>(new Array(10).fill(0)),
|
||||
[],
|
||||
);
|
||||
```
|
||||
|
||||
```tsx
|
||||
// dòng ~1270
|
||||
const tone = toneSync.getDirty();
|
||||
const hasTone =
|
||||
toneEffect != null &&
|
||||
(tone[0] !== 0 || tone[1] !== 0 || ... || tone[11] !== 0);
|
||||
```
|
||||
|
||||
Sửa:
|
||||
|
||||
1. `new Array(40).fill(0)` — đúng bằng độ dài `toneUniformArray`.
|
||||
2. `hasTone` thêm `tone[12] !== 0` (đây là `hslOn`).
|
||||
|
||||
Nếu bỏ sót, worklet đọc `undefined` ở các slot mới ⇒ uniform `NaN` ⇒ **cả tone pass hỏng trên preview camera**, không chỉ HSL.
|
||||
|
||||
Library (declarative `<Shader>`) — thêm key vào object `toneUniforms` (dòng ~2999):
|
||||
|
||||
```tsx
|
||||
const toneUniforms = {
|
||||
dr: toneParams[0],
|
||||
hl: toneParams[1],
|
||||
sh: toneParams[2],
|
||||
vib: toneParams[3],
|
||||
shTr: toneParams[4],
|
||||
shTg: toneParams[5],
|
||||
shTb: toneParams[6],
|
||||
hlTr: toneParams[7],
|
||||
hlTg: toneParams[8],
|
||||
hlTb: toneParams[9],
|
||||
cc: toneParams[10],
|
||||
ccb: toneParams[11],
|
||||
hslOn: toneParams[12],
|
||||
hslH: toneParams.slice(13, 21),
|
||||
hslS: toneParams.slice(21, 29),
|
||||
hslL: toneParams.slice(29, 37),
|
||||
gh: toneParams[37],
|
||||
gs: toneParams[38],
|
||||
gl: toneParams[39],
|
||||
};
|
||||
```
|
||||
|
||||
`toneOn = toneParams.some((v) => v !== 0)` giữ nguyên — buffer phẳng nên vẫn đúng.
|
||||
|
||||
> **Cần xác nhận trên máy:** nhánh declarative `<Shader uniforms>` của RN Skia phải nhận **array** uniform (`float hslH[8]`). Nếu nó flatten/không nhận, chuyển nhánh library preview sang `makeShaderWithChildren(toneUniformArray(tone), [imageShader])` như camera/export đã làm — cùng một nguồn số, bỏ hẳn object named-uniform.
|
||||
|
||||
---
|
||||
|
||||
## 8. UI Android
|
||||
|
||||
### 8.1 `src/components/ToolRail.tsx`
|
||||
|
||||
```ts
|
||||
export type TabId = 'recipes' | 'favorites' | 'iq' | 'wb' | 'filters' | 'hsl' | 'frame';
|
||||
```
|
||||
|
||||
Thêm vào `TOOLS`, **giữa `filters` và `frame`** (web đặt HSL cùng nhóm màu):
|
||||
|
||||
```ts
|
||||
{ id: 'filters', label: 'FX' },
|
||||
{ id: 'hsl', label: 'HSL' },
|
||||
{ id: 'frame', label: 'FRAME' },
|
||||
```
|
||||
|
||||
### 8.2 State — nâng lên `App.tsx`
|
||||
|
||||
Web để state HSL trong `App`. Android cũng phải vậy, vì **cả `AdjustmentPanel` (chip) lẫn `Viewfinder` (eyedropper + panel nổi) đều cần**:
|
||||
|
||||
```tsx
|
||||
const [hslBand, setHslBand] = useState<HslBandId>('red'); // dải ruler đang sửa
|
||||
const [picking, setPicking] = useState(false); // eyedropper đã arm chưa
|
||||
const [hslSample, setHslSample] = useState<{ r: number; g: number; b: number } | null>(null);
|
||||
const [hslPickedAt, setHslPickedAt] = useState<{ fx: number; fy: number } | null>(null); // vị trí trên ảnh
|
||||
```
|
||||
|
||||
Truyền xuống `Viewfinder`: `picking`, `hslPickedAt`, `onPickColor`, và một node `hslPanel` (JSX panel, xem 8.5). Truyền xuống `AdjustmentPanel`: `hslBand`, `onHslBand`, `picking`, `onPicking`, `hslSample`, `adjustments`, `onUpdateAdjustments` (đã có).
|
||||
|
||||
Hai setter (đặt cạnh nhau, cả hai gọi `onUpdateAdjustments`):
|
||||
|
||||
```tsx
|
||||
// Một band triple như khi lưu. Dải về [0,0,0] bị XOÁ, và khi không còn dải nào
|
||||
// thì field trả về undefined (không phải {}) để `differs(adjustments,
|
||||
// DEFAULT_ADJUSTMENTS)` trong resetDirty không báo dirty sai.
|
||||
const setBandKnob = (which: 0 | 1 | 2, v: number) => {
|
||||
const band = adjustments.hslBands?.[hslBand] ?? ([0, 0, 0] as HslBand);
|
||||
const next: HslBand = [band[0], band[1], band[2]];
|
||||
next[which] = Math.round(v);
|
||||
const bands = { ...adjustments.hslBands };
|
||||
if (next[0] === 0 && next[1] === 0 && next[2] === 0) delete bands[hslBand];
|
||||
else bands[hslBand] = next;
|
||||
onUpdateAdjustments({ hslBands: Object.keys(bands).length ? bands : undefined });
|
||||
};
|
||||
|
||||
// Move toàn ảnh: một cửa cho cả ba knob, nên ruler và field nó ghi luôn là cùng
|
||||
// một giá trị dù mở từ đâu.
|
||||
const setHslGlobal = (which: 0 | 1 | 2, v: number) => {
|
||||
const key = which === 0 ? 'hslHue' : which === 1 ? 'hslSat' : 'hslLum';
|
||||
onUpdateAdjustments({ [key]: Math.max(-10, Math.min(10, Math.round(v))) } as Partial<ColorAdjustments>);
|
||||
};
|
||||
```
|
||||
|
||||
`pickColor` (App đưa xuống Viewfinder):
|
||||
|
||||
```tsx
|
||||
// Báo cáo của eyedropper: in màu đọc được, trỏ ruler về dải của màu đó, treo
|
||||
// panel lên đúng điểm nó được đọc, rồi cất dụng cụ đi — một lần pick một màu.
|
||||
const pickColor = (rgb: { r: number; g: number; b: number }, at: { fx: number; fy: number }) => {
|
||||
setHslSample(rgb);
|
||||
setHslBand(nearestHslBand(rgbToHsl(rgb.r, rgb.g, rgb.b).h));
|
||||
setHslPickedAt(at);
|
||||
setPicking(false);
|
||||
};
|
||||
```
|
||||
|
||||
### 8.3 `src/components/AdjustmentPanel.tsx`
|
||||
|
||||
1. `type TabId` (dòng 20) thêm `| 'hsl'`.
|
||||
2. `paramDefs: Record<TabId, ParamDef[]>` thêm `hsl: []` **hoặc** xử lý riêng trong `chipRow` (xem dưới). Ba knob toàn ảnh **không** vào `PARAM_DEFS` — chúng đọc/ghi field của `adjustments` nhưng label là `HUE IMAGE`/`SAT IMAGE`/`LUM IMAGE`, và mở row qua cùng cơ chế `openParam` với `PARAM_GROUP` để nút back đóng row.
|
||||
|
||||
Cách gọn nhất, khớp cấu trúc sẵn có: mở rộng `paramDefs.hsl` bằng 3 `ParamDef` với `key` là `'hsl.h' | 'hsl.s' | 'hsl.l'` và `onChange: (v) => setHslGlobal(which, v)`. Khi đó `openParamDef` + row slider sẵn có tự hoạt động, chip hiện value, RESET/back hoạt động.
|
||||
|
||||
```tsx
|
||||
// Ba knob toàn ảnh của mixer không nằm trong PARAM_DEFS: chúng đọc/ghi move
|
||||
// của CHÍNH ẢNH chứ không phải một field phẳng của adjustments, và khác panel
|
||||
// trên ảnh, chúng không gắn với dải mà mixer đang trỏ.
|
||||
hsl: [0, 1, 2].map((i) => {
|
||||
const own = [a.hslHue ?? 0, a.hslSat ?? 0, a.hslLum ?? 0];
|
||||
const key = i === 0 ? 'hsl.h' : i === 1 ? 'hsl.s' : 'hsl.l';
|
||||
const name = i === 0 ? 'HUE' : i === 1 ? 'SAT' : 'LUM';
|
||||
return {
|
||||
key,
|
||||
label: `${name} IMAGE`,
|
||||
value: own[i],
|
||||
default: 0,
|
||||
min: -10,
|
||||
max: 10,
|
||||
step: 1,
|
||||
display: (v: number) => (v > 0 ? `+${v}` : String(v)),
|
||||
onChange: (v: number) => onSetHslGlobal(i as 0 | 1 | 2, v),
|
||||
};
|
||||
}),
|
||||
```
|
||||
|
||||
3. Thêm `case 'hsl'` vào switch `chipRow`:
|
||||
|
||||
```tsx
|
||||
case 'hsl': {
|
||||
// PICK arm eyedropper; 8 chip dải chọn dải mà ruler sửa; ruler do ba
|
||||
// knob bên dưới mở. Ba knob đó là move toàn ảnh, không phải của dải:
|
||||
// chip dải chọn màu cho panel trên ảnh, còn HUE/SAT/LUM dưới divider
|
||||
// move mọi hue trong khung cùng lúc.
|
||||
return chipRow([
|
||||
{ key: 'hsl-pick', label: 'PICK', active: picking, onPress: () => onSetPicking(!picking) },
|
||||
...HSL_BANDS.map((b): ChipDef => {
|
||||
const band = a.hslBands?.[b.id];
|
||||
const moved = !!band && (band[0] !== 0 || band[1] !== 0 || band[2] !== 0);
|
||||
return {
|
||||
key: `hsl-band-${b.id}`,
|
||||
label: b.label,
|
||||
// Chip gọi tên một màu nên nó hiện đúng màu đó.
|
||||
color: hslToHex(b.hue, 70, 50),
|
||||
active: hslBand === b.id,
|
||||
amberValue: hslBand !== b.id && moved,
|
||||
onPress: () => onSetHslBand(b.id),
|
||||
};
|
||||
}),
|
||||
// Divider giữ ba slider toàn ảnh ra ngoài hàng màu: hàng trên chọn
|
||||
// một màu, ba cái này move tất cả.
|
||||
{ key: 'hsl-image', label: 'IMAGE', disabled: true, active: false, onPress: () => {} },
|
||||
...paramChips(paramDefs.hsl),
|
||||
], 8);
|
||||
}
|
||||
```
|
||||
|
||||
4. `ChipDef` cần thêm `color?: string` (chip màu). Trong `renderChip`, khi `c.color` có mặt, vẽ một dot màu trước label (Android dùng `View` với `backgroundColor`):
|
||||
|
||||
```tsx
|
||||
{c.color ? <View style={{ width: 10, height: 10, borderRadius: 999, backgroundColor: c.color, marginRight: 6 }} /> : null}
|
||||
```
|
||||
|
||||
Chip màu vẫn giữ text label (RED/ORANGE/…) — dot là phụ, không thay chữ.
|
||||
|
||||
5. Readout khi `activeTab === 'hsl'`: Android không có cột phụ như web. Đặt một **dòng readout mỏng ngay trên hàng chip** (chỗ `openParamDef` row đang render), chỉ khi tab hsl:
|
||||
|
||||
```tsx
|
||||
{activeTab === 'hsl' && (
|
||||
<View className="flex-row items-center gap-2 px-3 py-1">
|
||||
{hslSample ? (
|
||||
<>
|
||||
<View style={{ width: 22, height: 22, borderRadius: 4, borderWidth: 1, borderColor: '#3f3f46',
|
||||
backgroundColor: `rgb(${hslSample.r}, ${hslSample.g}, ${hslSample.b})` }} />
|
||||
<Text className="font-mono text-[11px] text-zinc-400">
|
||||
R {hslSample.r} · G {hslSample.g} · B {hslSample.b}
|
||||
</Text>
|
||||
<Text className="font-mono text-[11px] text-zinc-400">
|
||||
{Math.round(rgbToHsl(hslSample.r, hslSample.g, hslSample.b).h)}° ·{' '}
|
||||
{Math.round(rgbToHsl(hslSample.r, hslSample.g, hslSample.b).s * 100)}% ·{' '}
|
||||
{Math.round(rgbToHsl(hslSample.r, hslSample.g, hslSample.b).l * 100)}%
|
||||
</Text>
|
||||
<Text className="font-mono text-[11px] text-amber-400">{bandName}</Text>
|
||||
</>
|
||||
) : (
|
||||
<Text className="font-mono text-[10px] tracking-widest text-zinc-500">
|
||||
BẤM PICK RỒI CHỌN MỘT MÀU TRÊN ẢNH
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
```
|
||||
|
||||
`bandName = HSL_BANDS.find((b) => b.id === hslBand)?.label ?? ''`.
|
||||
|
||||
### 8.4 `hslToHex` (dùng cho chip màu)
|
||||
|
||||
Copy nguyên từ web (`docker/frontend/src/App.tsx` dòng 108) vào `src/utils/colorUtils.ts` hoặc ngay trong `AdjustmentPanel.tsx`:
|
||||
|
||||
```ts
|
||||
// HSL -> #rrggbb. Chip gọi tên một màu nên nó phải hiện đúng màu đó; hex là
|
||||
// format duy nhất RN nhận thẳng từ chuỗi.
|
||||
export function hslToHex(h: number, s: number, l: number): string {
|
||||
const k = (n: number) => (n + h / 30) % 12;
|
||||
const a = (s / 100) * Math.min(l / 100, 1 - l / 100);
|
||||
const chan = (n: number) =>
|
||||
Math.max(0, Math.min(255, Math.round(255 * (l / 100 - a * Math.max(-1, Math.min(k(n) - 3, 9 - k(n), 1))))))
|
||||
.toString(16)
|
||||
.padStart(2, '0');
|
||||
return `#${chan(0)}${chan(8)}${chan(4)}`;
|
||||
}
|
||||
```
|
||||
|
||||
### 8.5 Eyedropper + panel nổi — `src/components/Viewfinder.tsx`
|
||||
|
||||
Đây là phần **chưa có tương ứng Android**, phải viết mới.
|
||||
|
||||
**Props thêm:**
|
||||
|
||||
```tsx
|
||||
picking?: boolean;
|
||||
onPickColor?: (rgb: { r: number; g: number; b: number }, at: { fx: number; fy: number }) => void;
|
||||
hslPanel?: React.ReactNode;
|
||||
hslPanelAt?: { fx: number; fy: number } | null;
|
||||
```
|
||||
|
||||
**Lớp pick.** Web đặt một `div` phủ đúng **box của ảnh** (`box` = rect ảnh sau zoom/pan, không phải cả canvas). Android đã có `imageFitRect` và sẵn một touch layer trong library mode (`onLibTouchStart` với toạ độ fraction). Cách port đúng và ít code nhất:
|
||||
|
||||
- Khi `picking`, thêm một `Pressable` (hoặc `View` với `onStartShouldSetResponder`) phủ **đúng `imageFitRect`**, chặn touch để pan/zoom không khởi động.
|
||||
- Toạ độ fraction tính từ rect **của lớp đó**, không phải của canvas:
|
||||
|
||||
```tsx
|
||||
const fx = (e.nativeEvent.locationX) / rectW;
|
||||
const fy = (e.nativeEvent.locationY) / rectH;
|
||||
if (!(fx >= 0 && fx <= 1 && fy >= 0 && fy <= 1)) return;
|
||||
```
|
||||
|
||||
- Hiện một icon eyedropper đi theo ngón tay (`pick-icon` của web là SVG; Android dùng `lucide-react-native` đã có sẵn: `<Pipette size={22} color="#fff" />`).
|
||||
- Chỉ arm trong **library mode**. Camera mode không có pixel để sample (và preview là luồng sống).
|
||||
|
||||
**Lấy màu.** Web sample chính **ảnh preview đã render** (`previewUrl`), không phải file gốc — để màu đọc được đúng là màu đang thấy. Android tương đương: snapshot canvas đang vẽ. Đường ngắn nhất:
|
||||
|
||||
```tsx
|
||||
// canvasRef = useCanvasRef() trên canvas library đang vẽ `graded`.
|
||||
const shot = canvasRef.current?.makeImageSnapshot(); // ảnh đã render
|
||||
const px = await shot.readPixels(Math.floor(fx * shot.width()), Math.floor(fy * shot.height()), {
|
||||
width: 1, height: 1, colorType: ColorType.RGBA_8888, alphaType: AlphaType.Unpremul,
|
||||
});
|
||||
onPickColor({ r: px[0], g: px[1], b: px[2] }, { fx, fy });
|
||||
```
|
||||
|
||||
Đọc **1 pixel** từ snapshot là đủ và rẻ; cache snapshot trong lúc `picking` để mỗi lần chạm không phải snapshot lại.
|
||||
|
||||
> **ponytail:** snapshot canvas mỗi lần pick là đủ cho một lần chạm/ảnh; nếu độ trễ cảm nhận được trên máy thật, chuyển sang decode `skiaImage` một lần và `readPixels` trên đó — nhưng khi đó màu sample là màu **gốc**, không phải màu đã grade, và hue có thể lệch khỏi thứ người dùng nhìn thấy nếu các knob khác đã move. Chỉ hạ cấp khi có số đo.
|
||||
|
||||
**Panel nổi.** Web đặt panel tại `left = fx*100%`, `top = fy*100%` **trong box ảnh**, với `transform: translate(-50%, 14px)`, hoặc `translate(-50%, calc(-100% - 14px))` khi `fy > 0.55` (điểm nằm thấp thì panel treo lên trên để không rơi khỏi ảnh). Android absolute-position trong chính box đó:
|
||||
|
||||
```tsx
|
||||
{hslPanel && hslPanelAt && (
|
||||
<View
|
||||
pointerEvents="box-none"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: 0, top: 0, width: rectW, height: rectH,
|
||||
}}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: hslPanelAt.fx * rectW,
|
||||
top: hslPanelAt.fy * rectH,
|
||||
transform: [
|
||||
{ translateX: -PANEL_HALF_W }, // translate(-50%)
|
||||
{ translateY: hslPanelAt.fy > 0.55 ? -(PANEL_H + 14) : 14 },
|
||||
],
|
||||
}}
|
||||
>
|
||||
{hslPanel}
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
```
|
||||
|
||||
Panel dừng pointer để không rơi xuống lớp pan của ảnh.
|
||||
|
||||
### 8.6 Panel nội dung (`hslPanel`)
|
||||
|
||||
Web dựng `pickPanel` ở `App.tsx`; Android dựng trong `AdjustmentPanel` (nó đã có `Slider` + haptics) rồi App truyền node xuống Viewfinder, hoặc dựng thẳng trong App. Nội dung:
|
||||
|
||||
- **Swatch** — màu của sample sau khi áp knob của dải đang chọn:
|
||||
|
||||
```tsx
|
||||
const picked = hslSample ? rgbToHsl(hslSample.r, hslSample.g, hslSample.b) : null;
|
||||
const band = adjustments.hslBands?.[hslBand] ?? ([0, 0, 0] as HslBand);
|
||||
const mixedHex = picked
|
||||
? hslToHex(
|
||||
picked.h + band[0],
|
||||
Math.max(0, Math.min(100, picked.s * 100 + band[1])),
|
||||
Math.max(0, Math.min(100, picked.l * 100 + band[2])),
|
||||
)
|
||||
: null;
|
||||
```
|
||||
|
||||
- **Tên dải + hex**, nút đóng `✕` (`onPress={() => setHslPickedAt(null)}`).
|
||||
- **Ba MiniSlider** `HUE` / `SAT` / `LUM`, mỗi cái `value = band[i]`, `onValueChange = (v) => setBandKnob(i, v)`, và reset về 0 (web: double-click; Android: thêm một `TouchableOpacity` nhỏ hoặc `onSlidingComplete` khi |v| < 0.5 ⇒ 0). Dùng `Slider` (`@react-native-community/slider`) với `minimumValue={-10} maximumValue={10} step={1}`.
|
||||
|
||||
### 8.7 `resetDirty` — `App.tsx`
|
||||
|
||||
`differs()` là **shallow**. Với `setBandKnob` trả `hslBands: undefined` khi rỗng (8.2), `differs(adjustments, DEFAULT_ADJUSTMENTS)` đã bắt đúng cả `hslBands` lẫn `hslHue/hslSat/hslLum`. **Không cần thêm clause** — miễn là đừng để `hslBands: {}`.
|
||||
|
||||
Nếu vì lý do nào đó muốn giữ `{}`, thì phải thêm `|| Object.keys(adjustments.hslBands ?? {}).length > 0` (đúng như web) — nhưng cách `undefined` sạch hơn.
|
||||
|
||||
### 8.8 Camera mode + PRO gate — quyết định
|
||||
|
||||
- **Camera mode:** mixer tự động chạy trong preview camera (7.1 sửa xong `toneSync`), nên tab HSL cứ hiện ở cả hai mode. **PICK + panel chỉ có nghĩa ở library** — ở camera mode cho chip `PICK` ở trạng thái `disabled` (chip xám, không mở). Đây là điểm khác web (web library-only) và là chỗ dễ tranh cãi nhất — **xác nhận với người dùng nếu muốn khác**.
|
||||
- **PRO gate:** web **không** gate HSL (`proLookInUse()` chỉ phủ `PRO_FRAMES` + GPS + HDF; tab HSL mở cho guest). Port giữ nguyên: **HSL là tính năng LITE**, không thêm vào `entitlement.ts`.
|
||||
- **`RecipeCreateModal`:** không cần thêm dòng HSL. Web cũng không có HSL trong form CREATE — mixer chỉ chỉnh trên ảnh, recipe lưu qua `adjustments` khi SAVE.
|
||||
|
||||
---
|
||||
|
||||
## 9. Kiểm tra
|
||||
|
||||
### 9.1 Trên Android (self-check + unit)
|
||||
|
||||
1. **`HSL_BANDS` / `hslBandGaps`** — bất biến partition of unity:
|
||||
|
||||
```ts
|
||||
// Với mọi hue 0..359, tổng 8 tent weight phải = 1 (±1e-6).
|
||||
const gaps = hslBandGaps();
|
||||
for (let h = 0; h < 360; h += 1) {
|
||||
const sum = gaps.reduce((s, b) => {
|
||||
const d = ((h - b.hue + 540) % 360) - 180;
|
||||
const w = d <= 0 ? Math.max(0, 1 + d / b.left) : Math.max(0, 1 - d / b.right);
|
||||
return s + w;
|
||||
}, 0);
|
||||
if (Math.abs(sum - 1) > 1e-6) throw new Error(`weights != 1 at ${h}: ${sum}`);
|
||||
}
|
||||
```
|
||||
|
||||
Và `hslBandGaps()` trả `red.left = 40`, `red.right = 30` (wrap đúng).
|
||||
|
||||
2. **`sanitizeHslBands`**:
|
||||
- `{ red: [99, -99, 0] }` → `{ red: [10, -10, 0] }`
|
||||
- `{ red: [0,0,0] }` → `undefined`
|
||||
- `{ red: [1,2] }`, `{ nope: [1,2,3] }` → bỏ
|
||||
- `null` / `'x'` → `undefined`
|
||||
|
||||
3. **`rgbToHsl` ↔ `nearestHslBand`**: đỏ thuần `(255,0,0)` → `red`; `(255,128,0)` → `orange`; xám `(128,128,128)` → `s = 0`, `nearestHslBand(0) = 'red'` (rồi shader gate nó đi).
|
||||
|
||||
4. **Shader compile** — `Skia.RuntimeEffect.Make(TONE_SKSL)` không throw. Bắt buộc, vì `${BAND_BLOCK}` nội suy vào chuỗi.
|
||||
|
||||
5. **`toneUniformArray`** — `length === 40`; `hslOn` ở index 12; `gh/gs/gl` ở 37/38/39.
|
||||
|
||||
6. **`toneIsActive`** — `{ ...all zero, hslOn: 1 }` → `true`; `{ ...all zero }` → `false`.
|
||||
|
||||
7. **`getToneUniforms`** — `baseFilter: 'monochrome'` + `hslBands` có giá trị ⇒ `hslOn === 0`; `colour` + `hslLum: 10` ⇒ `gl === 1`, `hslOn === 1`.
|
||||
|
||||
8. **Round-trip `.recipe`** — recipe có `hslBands: { blue: [3,-2,5], red: [0,0,0] }` export rồi import: `red` biến mất, `blue` giữ nguyên; `hslHue/Sat/Lum` sống qua vòng.
|
||||
|
||||
9. **Máy / emulator** (kiểm tra số, không nhìn ảnh):
|
||||
- Preview camera với `+10` dải RED và ảnh có mảng đỏ: sample pixel đỏ trước/sau bằng `readPixels` trên cùng toạ độ ⇒ hue trôi ~+30°.
|
||||
- `-10` SAT dải RED trên vùng đỏ ⇒ saturation về ~0; `+10` LUM ⇒ lightness tăng ~0.25.
|
||||
- `hslLum = +10` trên một ảnh đã `saturation = -10`: lightness vẫn phải tăng (kiểm tra `gl` không bị gate).
|
||||
- Monochrome stock: uniform HSL toàn 0 bất kể `hslBands` có gì.
|
||||
- Export ra file: pixel cuối phải khớp pixel preview (cùng một `toneUniformArray`).
|
||||
|
||||
### 9.2 Tham chiếu hành vi từ web
|
||||
|
||||
Các script e2e của web là **hợp đồng hành vi** để đối chiếu, nằm trong scratchpad:
|
||||
|
||||
```
|
||||
/home/locpham/.penguin/data/default_project/agents/default_agent/scratchpad/session-2026-09-06-18-30-42-c24c8c7b/
|
||||
hsl-test.cjs # chips dải, setBandKnob, xoá dải toàn 0
|
||||
hsl-panel-test.cjs # PICK -> pickColor -> panel nổi -> knob panel
|
||||
hsl-shader-test.cjs # partition of unity + kết quả shader theo hue
|
||||
hsl-bundle.cjs # build standalone
|
||||
hsl-entry.ts # entry
|
||||
```
|
||||
|
||||
Web đã deploy (`docker/frontend`) là bản chạy được để so từng bước: mở tab `hsl`, PICK một màu, kéo HUE/SAT/LUM trên panel và trên ruler `IMAGE`, đối chiếu `R n · G n · B n` / `h° · s% · l%`.
|
||||
|
||||
---
|
||||
|
||||
## 10. Thứ tự thực hiện (đề xuất)
|
||||
|
||||
1. `types` → 2. `colorUtils` (HSL_BANDS … sanitize) → 3. `toneShader` (4.1–4.8) → 4. self-check 9.1.1–9.1.7 chạy pass → 5. `defaultRecipes` + `recipeShare` → 6. `Viewfinder` buffer (7.1) — **làm trước UI**, nếu không preview camera sẽ vỡ → 7. `ToolRail` + `AdjustmentPanel` chip/readout → 8. `Viewfinder` eyedropper + panel + state App → 9. máy thật.
|
||||
@@ -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,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,78 @@
|
||||
# RecipesCam
|
||||
|
||||
RecipesCam is a camera and photo-editing app built around **recipes** — reusable
|
||||
looks that carry a film simulation plus a full set of adjustments. You shoot or
|
||||
open a photo, dial in a look, and keep it as a recipe you can apply again, share
|
||||
as a `.recipe` file, or save to your account.
|
||||
|
||||
It ships twice from one repository: a **React Native (Expo) app** for iOS and
|
||||
Android, and a **self-contained web build** under `docker/` that runs the same
|
||||
render pipeline in the browser.
|
||||
|
||||
## What it does
|
||||
|
||||
- **Shoot with a recipe applied.** Live viewfinder, GPS-tagged captures, and the
|
||||
recipe baked into the saved file.
|
||||
- **Film simulations.** Built-in looks — PROVIPES, VELVIPES, CLASSIC CHRIPES,
|
||||
CLASSIC NEGIPES, ASTIPES, ETERNIPES, ACRIPES, B&W HIGH CONTRAST and the LC
|
||||
STREETLIFE pair — each with its own grain and tone curve.
|
||||
- **The full adjustment set.** Exposure, contrast, highlights and shadows,
|
||||
saturation, colour temperature, clarity, grain, and an HSL mixer with a
|
||||
colour picker that samples straight off the photo.
|
||||
- **Geometry.** Crop to a fixed ratio or free-form, quarter turns, and a
|
||||
straighten ruler, plus printed frames (classic border, retro instant, wall
|
||||
frame portrait/landscape).
|
||||
- **Finishing.** Watermark and GPS stamp, EXIF carried through the export, JPEG
|
||||
written with a proper 300 DPI JFIF header.
|
||||
- **On-device upscaling.** A Real-ESRGAN pass runs locally when an export asks
|
||||
for more pixels than the source has — no server sees the photo.
|
||||
- **Recipes.** Save, favourite, export and import `.recipe` files; the web
|
||||
build keeps them in your account, the phone build also keeps them on device.
|
||||
|
||||
## The two builds
|
||||
|
||||
| Build | Where | Stack |
|
||||
| --- | --- | --- |
|
||||
| iOS / Android | repo root | Expo + React Native, `@shopify/react-native-skia` for the render pipeline, NativeWind for styling |
|
||||
| Web | `docker/` | Vite + React, CanvasKit (`canvaskit-wasm`) for the same pipeline, Fastify + SQLite API for accounts and recipes |
|
||||
|
||||
The render engine is shared by design: the web build compiles the app's own
|
||||
`src/utils/*` and type definitions unchanged, with `@shopify/react-native-skia`
|
||||
aliased to a CanvasKit shim (`docker/frontend/src/engine/skiaShim.ts`). A look
|
||||
looks the same on both because it is the same code.
|
||||
|
||||
## Running the web build
|
||||
|
||||
```bash
|
||||
cd docker
|
||||
cp .env.example .env
|
||||
docker compose up -d --build
|
||||
# → http://localhost:8090
|
||||
```
|
||||
|
||||
Photos never leave the browser: grading, framing, watermarking and JPEG
|
||||
encoding all run in the visitor's tab; the API only stores accounts and recipe
|
||||
JSON. See `docker/README.md` for the layout and the proxy setup.
|
||||
|
||||
## Running the app
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npx expo start # Expo Go / dev client
|
||||
npx expo run:android # or run:ios for a native build
|
||||
```
|
||||
|
||||
## Repository layout
|
||||
|
||||
```
|
||||
App.tsx, src/ the Expo app: screens, tool rail, viewfinder, shaders
|
||||
docker/ the web build (frontend + API + compose file)
|
||||
frontend/shared/ vendored copies of the app's types and utils
|
||||
frontend/src/engine/ CanvasKit shim, export engine, super-resolution
|
||||
docs/ privacy policy
|
||||
THIRD_PARTY_NOTICES.md licences of the bundled fonts, models and libraries
|
||||
```
|
||||
|
||||
## Licence
|
||||
|
||||
See `LICENSE` and `THIRD_PARTY_NOTICES.md`.
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
# Release notes
|
||||
|
||||
## 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.
|
||||
```
|
||||
@@ -0,0 +1,249 @@
|
||||
# WEB_PLAN — RecipesCam Web (Docker, branch `recipes-web`)
|
||||
|
||||
Trạng thái: kế hoạch đã chốt yêu cầu, chưa code. Ngày: 2026-09-06.
|
||||
Nguồn đối chiếu: app RN hiện tại (`App.tsx`, `src/components/*`, `src/utils/*`).
|
||||
|
||||
## Mục tiêu
|
||||
Một web app chạy bằng Docker, gồm 2 phần:
|
||||
|
||||
1. **Landing page** (`/`, responsive cả desktop lẫn mobile) — giới thiệu app RecipesCam: hero, tính năng, ảnh before/after, review người dùng, badge Google Play / App Store, CTA **DÙNG THỬ DEMO** và **ĐĂNG KÝ**.
|
||||
2. **WebUI chỉnh ảnh** (`/app`) — tái hiện giao diện app điện thoại:
|
||||
- **Cột trái**: rail 6 tab (PRESETS / FAVORITED / LIGHT / WB / FX / FRAME); chip công cụ của tab đang chọn nằm trong cột đó.
|
||||
- **Giữa**: khung ảnh lớn, **kéo-thả ảnh từ máy tính** vào để nạp (kèm chọn file + paste clipboard). Trên mobile: nút chọn ảnh / camera roll.
|
||||
- Đầy đủ tool như app → **EXPORT / RENDER** ra JPEG (DPI 300) tải về.
|
||||
- **Add-on BATCH EXPORT**: nạp nhiều ảnh, áp cùng 1 recipe (của người dùng hoặc bundle), export ra cùng một tông màu.
|
||||
3. **Tài khoản**: bắt buộc đăng nhập để dùng bản đầy đủ; vẫn cho **demo không cần đăng nhập**.
|
||||
4. Toàn bộ xử lý ảnh chạy **client-side** (không upload ảnh lên server) — server chỉ phục vụ file tĩnh + API tài khoản/recipe.
|
||||
|
||||
## Quyết định đã chốt (2026-09-06)
|
||||
1. **Cổng/domain**: chạy `IP:port` (mặc định `8090`), đổi được qua `docker-compose.yml`; domain thật gắn sau bằng **Nginx Proxy Manager** bên ngoài → không cần TLS trong container, chỉ cần `X-Forwarded-*` đúng.
|
||||
2. **Auth/multi-user**: có. Đăng ký/đăng nhập email + mật khẩu, session cookie; **vẫn cho dùng demo** không cần tài khoản.
|
||||
3. **Batch export**: có, là công cụ add-on cho người dùng đã đăng nhập — nhiều ảnh dùng chung 1 recipe, render tuần tự có tiến trình.
|
||||
4. **Responsive**: hỗ trợ cả desktop và mobile browser.
|
||||
5. **EXIF GPS**: đọc GPS từ ảnh nạp vào để tự điền watermark; nếu ảnh không có GPS thì cho nhập tay (place name + toạ độ).
|
||||
6. **Review trên landing**: nội dung tĩnh trong repo (`web/src/content/reviews.json`) — sửa file + rebuild; không cần CRUD/admin ở v1.
|
||||
`ponytail:` seed tĩnh là đủ vì review đổi rất chậm; nâng lên bảng DB + admin API khi cần tự đăng review.
|
||||
7. **Theme**: nhiều bộ theme — `light` / `dark` + các nhóm màu accent (xem mục Theme).
|
||||
8. **Đa ngôn ngữ**: có — tiếng Việt + tiếng Anh ở v1 (xem mục i18n).
|
||||
|
||||
### Phân hạng tính năng
|
||||
| Hạng | Điều kiện | Quyền |
|
||||
|---|---|---|
|
||||
| Guest | không đăng nhập | Landing, xem review, **DEMO**: nạp 1 ảnh, dùng tool (cấu hình ở `web/src/config/tiers.ts`), export **tối đa 2048px + watermark "RecipesCam demo"**, không lưu recipe |
|
||||
| User | đã đăng nhập | Toàn bộ tool như app, export **full-res không watermark**, lưu/favorite/share recipe (đồng bộ server), **BATCH EXPORT** |
|
||||
|
||||
Bảng hạng để trong 1 file config duy nhất để đổi chính sách mà không sửa UI.
|
||||
|
||||
## Theme (light/dark + nhóm màu)
|
||||
- **Cơ chế**: CSS custom properties trên `:root`, chuyển bằng thuộc tính `data-theme` + `data-accent` trên `<html>`; mọi màu trong UI đi qua token, không hardcode. Đổi theme = set 2 attribute, không re-render React.
|
||||
- **Theme nền**: `light`, `dark` (mặc định theo `prefers-color-scheme`, người dùng chọn được thì ghi đè). Dark dùng nền đen thuần theo `web-design`.
|
||||
- **Nhóm màu accent**: `amber` (mặc định — đúng chất RecipesCam), `blue`, `emerald`, `rose`, `violet`. Accent chỉ đổi token `--accent*`, không đổi layout.
|
||||
- **Lưu**: `localStorage` (`theme`, `accent`); đồng bộ sang `<meta name="theme-color">` cho mobile browser.
|
||||
- **UI chọn**: popover trong header (icon bảng màu) trên cả landing lẫn workspace; landing cũng đổi theme được.
|
||||
- `ponytail:` 2 theme nền + 5 accent là cấu hình dữ liệu trong `web/src/theme/themes.ts`; thêm theme mới = thêm 1 object, không sửa component.
|
||||
|
||||
## Đa ngôn ngữ (i18n)
|
||||
- **Ngôn ngữ v1**: `vi` (mặc định theo `navigator.language` nếu là tiếng Việt) + `en`. Thêm ngôn ngữ = thêm 1 file từ điển.
|
||||
- **Cách làm**: tự viết, **không thêm thư viện** — từ điển `web/src/i18n/vi.ts` / `en.ts` (object lồng nhau, có kiểu từ `vi` để `en` thiếu key là lỗi typecheck), `t(key, params)` + `I18nProvider` giữ `lang` trong `localStorage`. Đổi ngôn ngữ = đổi state, không reload.
|
||||
- **Phạm vi dịch**: toàn bộ nhãn UI (rail tab, chip, slider, tooltip, nút, thông báo lỗi, landing/review/auth form), tiêu đề trang + SEO (`<html lang>`, OG locale).
|
||||
- **Không dịch**: tên film sim (`PROVIPES`, `LC STREETLIFE CLASSIC`…), tên recipe người dùng đặt, nội dung watermark người dùng nhập — là danh từ riêng/thương hiệu, giữ nguyên ở mọi ngôn ngữ.
|
||||
- `ponytail:` từ điển là file TS được bundle; chỉ tách lazy-load theo ngôn ngữ khi bundle vượt ~50KB text.
|
||||
|
||||
## Kiến trúc
|
||||
```
|
||||
docker-compose.yml
|
||||
├── web nginx:alpine 8090:80 static SPA (Vite dist) + proxy /api → api
|
||||
└── api node:20-alpine 3000 Fastify + better-sqlite3, volume ./data
|
||||
```
|
||||
- **Frontend**: Vite + React + TS + react-router (`/`, `/app`, `/login`, `/signup`).
|
||||
- **Engine ảnh**: **CanvasKit (canvaskit-wasm)** trong Web Worker — nhưng **bắt buộc trên surface WebGL** (`MakeWebGLCanvasSurface(new OffscreenCanvas(w,h))`), không phải raster CPU: xem W0. Preview bản thu nhỏ (~2048px), render full-res khi export.
|
||||
- **API**: Fastify + better-sqlite3 (1 file DB, không cần container DB riêng). Session cookie `httpOnly` + `SameSite=Lax`, mật khẩu hash `argon2`. Bảng: `users`, `sessions`, `recipes`, `favorites`.
|
||||
- **Recipe bundle** lấy thẳng từ `src/utils/defaultRecipes.ts` (client-side) — server chỉ lưu recipe người dùng tạo.
|
||||
- Nginx Proxy Manager bên ngoài lo TLS/domain; container chỉ nghe HTTP.
|
||||
|
||||
### Vì sao CanvasKit
|
||||
Tái dùng gần như nguyên vẹn `src/utils/exportEngine.ts`: CanvasKit có `RuntimeEffect` (SkSL y nguyên `TONE_SKSL`, `CINEMA_SKSL`), `ColorFilter.MakeMatrix`, `ImageFilter.MakeBlur`/`MakeCompose`, `BlendMode.Overlay`, `drawImageRect`, `MakeFreeTypeFaceFromData`, `encodeToBytes(JPEG, 95)` → parity cao nhất với app, code ít nhất.
|
||||
Hai chỗ CanvasKit khác Skia JSI (đã xử lý ở W0): encode JPEG cần build `canvaskit-wasm/full`; không có `ImageFilter.MakeMatrixConvolution` → CLARITY/sharpening đi qua `CLARITY_SKSL`.
|
||||
**Không** dùng `react-native-web`/Expo web: app phụ thuộc `react-native-vision-camera` + Skia JSI, web sẽ vỡ.
|
||||
|
||||
## Tái sử dụng (không viết lại)
|
||||
| Nguồn trong repo | Dùng cho web |
|
||||
|---|---|
|
||||
| `src/utils/colorUtils.ts` | `getSkiaColorMatrix`, `applyExposureGain`, `kelvinToRGB` — nguyên văn |
|
||||
| `src/utils/toneShader.ts` | `TONE_SKSL` + `getToneUniforms` |
|
||||
| `src/utils/cinemaShader.ts` | `CINEMA_SKSL` + `getCinemaUniforms` |
|
||||
| `src/utils/frameUtils.ts` | `FRAMES`, `polaroidLayout`, `wallframeLayout`, `drawFrameOnCanvas`, hằng số POLAROID/WALLFRAME |
|
||||
| `src/utils/paramDefs.ts` | `PARAM_DEFS` (slider bounds/label/format) → render chip + slider |
|
||||
| `src/utils/defaultRecipes.ts` | `FILM_SIMS`, `DEFAULT_ADJUSTMENTS`, recipe bundle |
|
||||
| `src/utils/jpegDpi.ts` | `patchJpegDpi(bytes, 300)` — chạy trong worker |
|
||||
| `src/utils/recipeShare.ts` | IMPORT/EXPORT recipe chia sẻ giữa app ↔ web (cùng định dạng) |
|
||||
| `src/types/index.ts` | `ColorAdjustments`, `Recipe`, `FrameId`, `AspectRatio`, `CropRatio`, `CropRect`, `ASPECT_RATIO_W_H`, `CROP_W_H` |
|
||||
| `assets/Cousine-Regular.ttf`, `assets/NotoEmoji-GPS.ttf`, `wallframe.png` | font + artwork frame |
|
||||
|
||||
Viết mới: `web/src/engine/skiaShim.ts` (adapter API Skia trên CanvasKit) + `web/src/engine/exportEngine.ts` (port từ `src/utils/exportEngine.ts`, bỏ `expo-file-system`/MediaLibrary → Blob/download).
|
||||
|
||||
## Bản đồ UI app → web
|
||||
| Khu vực app | Web |
|
||||
|---|---|
|
||||
| `ToolRail` (rail ngang dưới màn hình) | Cột dọc bên trái, 6 tab; mobile: rail ngang dưới đáy |
|
||||
| `AdjustmentPanel` chip row + strip | Cột thứ 2 cạnh rail (~280–320px): chip của tab + strip/slider mở tại chỗ |
|
||||
| `Viewfinder` | Canvas giữa, nhận drag&drop file ảnh |
|
||||
| Drag-on-image để đổi slider | Giữ: kéo dọc trên ảnh = chỉnh param đang mở |
|
||||
| PEEK (giữ để so sánh) | Giữ chuột phải hoặc phím `\` |
|
||||
| `TopBar` | Header: tên recipe, UNDO/RESET, SAVE AS, EXPORT, IMPORT/EXPORT recipe, tài khoản, **đổi theme**, **đổi ngôn ngữ**; ẩn mục camera-only |
|
||||
| `PhotoViewerModal` | Strip thumbnail kết quả export ở đáy |
|
||||
| `SettingsModal` | Popover settings; bỏ mục camera-only; giữ PHOTO RATIO |
|
||||
|
||||
### Danh sách tool đầy đủ theo tab (lấy từ code hiện tại)
|
||||
- **PRESETS**: PHOTO STYLE (`provia`, `velvia`, `classic-chrome`, `classic-neg`, `astia`, `eterna`, `monochrome`, `leica`, `leica-vivid`) · RECIPES (bundle + user, có star) · CREATE · IMPORT.
|
||||
- **FAVORITED**: recipe đã star.
|
||||
- **LIGHT (iq)**: EXPOSURE · EV · CONTRAST · COLOR · VIBRANCE · HIGHLIGHT · SHADOW · D.RANGE (AUTO/DR100/200/400).
|
||||
- **WB**: TEMP (AUTO / DAYLIGHT / DAYLIGHT -3R / CLOUDY / SHADE / TUNGSTEN / FLUOR + slider COLOR TEMP) · TINT · COLOR CHROME · CHROME BLUE.
|
||||
- **FX (filters)**: NOISE REDUCTION · CLARITY · SHARPENING · MONOCHROME GRAIN · HDF EFFECT · VIGNETTING.
|
||||
- **FRAME**: NO FRAME / CLASSIC BORDER / RETRO INSTANT (polaroid) / WALL FRAME (+ WALL LANDSCAPE) · CROP (none/free/1:1/2:3/3:2/3:4/4:3/16:9 + APPLY) · ROTATE (0/90/180/270 + STRAIGHTEN + AUTO STRAIGHTEN) · WATERMARK (GPS WATERMARK ON/OFF, PLACE NAME, TIME, TEXT COLOR/SIZE/FONT/ROTATE, CUSTOM WATERMARK) · PHOTO RATIO (FULL/4:3/3:2).
|
||||
|
||||
### Metadata khi export — phải khớp app (300 ppi + EXIF)
|
||||
Đường export mặc định của app (`src/utils/exportEngine.ts`, tức `EXPO_PUBLIC_NATIVE_EXPORT != 1`) ghi JPEG đúng 3 bước, **theo thứ tự này**:
|
||||
|
||||
1. `encodeToBytes(JPEG, 95)` — Skia re-encode **làm mất toàn bộ EXIF nguồn**.
|
||||
2. `patchJpegDpi(bytes, 300)` — JFIF density `units=1 X=Y=300` (kèm `XResolution`/`YResolution` nếu JPEG có EXIF).
|
||||
3. `writeJpegExif(bytes, stamp, sourceBytes)` — dựng lại APP1/EXIF: **copy từ ảnh nguồn mọi tag app không sở hữu** (hãng máy, model, lens, phơi sáng, ISO…), **bỏ IFD1 thumbnail + MakerNote**, rồi đóng dấu `Software = "RecipesCam 1.2"`, `DateTimeOriginal`, GPS, tên địa điểm (0x889d), blob thiết bị (0x889e), kích thước pixel, dpi.
|
||||
|
||||
Web phải làm y hệt, cùng thứ tự, **giữ nguyên kích thước pixel và 300 ppi**:
|
||||
- `src/utils/exifWrite.ts` và `src/utils/jpegDpi.ts` **không có import nào** → tái dùng/port **nguyên văn**, chạy trên `Uint8Array` trong worker; web đã có sẵn bytes ảnh nguồn nên không cần `readPhotoBytes`.
|
||||
- Chỉ `src/utils/photoMeta.ts` mới dính Expo (`getDeviceIdentity`) → web truyền `device` từ nguồn khác hoặc để `null` (mọi field của `ExifStamp` đều optional).
|
||||
- Trạng thái hiện tại: `web/src/engine/exportEngine.ts` mới có bước 1+2 (đã kiểm chứng: JFIF `x=300 y=300`) — **bước 3 chưa port** → làm ở W4. Nhớ `patchJpegDpi` phải chạy **trước** `writeJpegExif`, và truyền `dpi` vào stamp để `XResolution`/`YResolution` khớp JFIF.
|
||||
|
||||
### Khác biệt web (bắt buộc xử lý)
|
||||
- **GPS watermark**: đọc GPS từ EXIF ảnh nạp vào (`exifr`); ảnh không có GPS → cho nhập tay place name + toạ độ. Reverse-geocode tên địa điểm là tuỳ chọn (gọi Nominatim), mặc định tắt. Cùng GPS đó cũng đi vào EXIF xuất ra (bước 3 ở trên), không chỉ vào watermark.
|
||||
- **Crop/rotate/straighten**: thao tác chuột/chạm trực tiếp trên canvas.
|
||||
- **Export**: CanvasKit `encodeToBytes(JPEG, 95)` → `patchJpegDpi(300)` → `writeJpegExif(...)` → tải `recipescam_<tên>_<timestamp>.jpg`; batch → ZIP (`fflate`, ~8KB) hoặc tải lần lượt. Không re-encode lần hai, không đổi kích thước pixel.
|
||||
- **Trạng thái**: `localStorage` thay `AsyncStorage`; recipe đã lưu đồng bộ server khi đã đăng nhập.
|
||||
|
||||
## Cấu trúc thư mục đề xuất
|
||||
```
|
||||
web/
|
||||
Dockerfile nginx.conf docker-compose.yml
|
||||
package.json vite.config.ts tsconfig.json index.html
|
||||
api/ # Fastify + better-sqlite3 (build riêng, chạy trong container api)
|
||||
server.ts auth.ts recipes.ts db.ts Dockerfile
|
||||
src/
|
||||
main.tsx App.tsx router.tsx config/tiers.ts config/storeLinks.ts
|
||||
content/reviews.json content/features.json
|
||||
theme/ (tokens.css, themes.ts, ThemeProvider.tsx, ThemePopover.tsx)
|
||||
i18n/ (vi.ts, en.ts, I18nProvider.tsx, t.ts)
|
||||
pages/ (Landing, Login, Signup, Workspace)
|
||||
ui/ (ToolRail, ChipColumn, ImageStage, SliderRow, StripRow, ThumbStrip, SettingsPopover, BatchPanel, AccountMenu)
|
||||
engine/ (skiaShim.ts, exportEngine.ts, worker.ts, previewEngine.ts, exifGps.ts)
|
||||
state/ (recipeStore.ts, authStore.ts)
|
||||
assets/ (fonts, wallframe.png — import từ ../assets)
|
||||
```
|
||||
Docker build context = repo root (cần `src/utils`, `assets/`), `-f web/Dockerfile`.
|
||||
|
||||
## Phases
|
||||
|
||||
### W0 — Spike CanvasKit (parity gate) ← đã chạy, còn cổng parity
|
||||
Chuỗi 1 ảnh: decode → crop → matrix → tone → cinema → denoise/clarity → HDF → grain → vignette → frame → watermark → JPEG 95 + DPI 300.
|
||||
Exit: chạy trong Node trên ảnh mẫu; so sánh với ảnh export từ app (meanAbs/gt12 như probe P6); đo thời gian 12MP; xác nhận FreeType font `Cousine` + `NotoEmoji-GPS` render (emoji không ra → fallback Canvas2D overlay).
|
||||
Rủi ro lớn nhất của cả kế hoạch → làm trước.
|
||||
|
||||
#### Kết quả đo được (2026-09-06, spike trong `web/spike/`)
|
||||
Bản port đã chạy hết chuỗi trên ảnh 12MP và ra JPEG; 4 phát hiện làm đổi kế hoạch:
|
||||
|
||||
1. **JPEG encode**: build `canvaskit-wasm` mặc định **không** encode được JPEG (`encodeToBytes(JPEG)` → null; PNG vẫn được). → dùng build `canvaskit-wasm/full` (8.2MB wasm so với 7.3MB). Shim import `canvaskit-wasm/full`.
|
||||
2. **`ImageFilter.MakeMatrixConvolution` không tồn tại** trong CanvasKit (chuỗi wasm có nhưng không có binding JS). → thay bằng `CLARITY_SKSL` (lấy từ `src/utils/toneShader.ts`, vốn đã dùng cho preview RN) chạy như shader pass, `px=(1,1)` ở tỉ lệ 1:1. Không phải viết thuật toán mới.
|
||||
3. **Font ✓**: `Typeface.MakeFreeTypeFaceFromData` + `Canvas.drawText` chạy. `Cousine-Regular.ttf` **thiếu** 📍/📷 (glyphID 0) nhưng `NotoEmoji-GPS.ttf` có (glyphID 1,2) và vẽ ra 1731 pixel → **không cần fallback Canvas2D emoji**.
|
||||
4. **Raster CPU không dùng được, buộc phải render trên surface WebGL**. Đo trên 4000×3000 (`spike/bench.ts`, SkVM interpreter):
|
||||
|
||||
| Bước | 4000×3000 | 2048 |
|
||||
|---|---|---|
|
||||
| draw | 5ms | — |
|
||||
| tone SkSL pass | **3205ms** | 836ms |
|
||||
| clarity SkSL 5-tap | **13745ms** | 3581ms |
|
||||
| blur σ=40 | **3024ms** | — |
|
||||
| JPEG q95 | 246ms | 54ms |
|
||||
| decode | 233ms | — |
|
||||
|
||||
Cùng pipeline trên surface WebGL (`spike/gpu.html`, headless Chromium, ANGLE/SwiftShader — **software GL**, GPU thật sẽ nhanh hơn hoặc bằng):
|
||||
- `MakeWebGLCanvasSurface(new OffscreenCanvas(w,h))` OK, tạo surface 15–22ms.
|
||||
- tone / clarity / blur pass: **0.1–1ms** (submit bất đồng bộ).
|
||||
- **JPEG q95: 205ms @12MP, 54ms @2048, 14ms @1024**; decode 12MP 280ms.
|
||||
|
||||
Đầu ra kiểm chứng trên ảnh 12MP (raster CPU, `spike/w0.ts`): 3 file JPEG 4.1–5.1MB, JFIF `units=1 x=300 y=300` (DPI 300 đúng), byte-mean ~125 → **ảnh không trắng, pipeline chạy hết chuỗi**.
|
||||
|
||||
→ Chốt: engine **bắt buộc** chạy trên WebGL surface (`MakeWebGLCanvasSurface`), raster chỉ là fallback cho test Node. Preview ~2048px, full-res khi export.
|
||||
|
||||
Các API khác đã xác nhận có trong CanvasKit: `MakeCompose`, `MakeBlur`, `MakeRadialGradient`, `RuntimeEffect.Make`, `makeShaderWithChildren`, `MakeSurface`, `Font`, `TypefaceFontProvider`, `MakeOffscreen`, `MakeWebGLCanvasSurface`.
|
||||
Gotcha khi viết shim: `new CK.Paint()` (không phải `CK.Paint()`); `img.readPixels(srcX, srcY, imageInfo)`; `CK.ImageFormat` là enum object; phải `flush(surface)` trước mỗi snapshot.
|
||||
|
||||
**Còn lại của W0 (chặn parity)**: cần **1 cặp ảnh mẫu từ người dùng** — ảnh gốc + đúng ảnh app đã export từ ảnh đó — để chạy diff meanAbs/gt12 như P6. Cặp `app.jpg`/`native.jpg` đang có trong scratchpad không rõ nguồn nên không dùng làm chuẩn được. Sau đó chạy lại 1 lần end-to-end trên WebGL để lấy số 12MP thật trong browser.
|
||||
|
||||
### W1 — Scaffold + layout + theme + i18n + nạp ảnh + responsive
|
||||
Vite/React/TS/router; 3 vùng desktop / 1 cột mobile; rail 6 tab; chip column rỗng; canvas giữa; nạp ảnh bằng drop/chọn file/paste; preview downscale 2048px (worker); zoom/pan.
|
||||
Theme: `tokens.css` (light/dark + 5 accent), `ThemeProvider`, popover chọn theme; i18n: `I18nProvider` + từ điển vi/en + nút đổi ngôn ngữ, mọi nhãn đi qua `t()`.
|
||||
Exit: thả ảnh từ desktop → hiển thị; đổi theme/ngôn ngữ tức thì và giữ sau reload; `npm run build` + typecheck sạch; mở đúng trên khổ mobile.
|
||||
|
||||
### W2 — Tool phi cấu trúc ảnh
|
||||
PRESETS, FAVORITED, LIGHT, WB, FX; slider/chip/strip (một row mở tại thời điểm); undo/reset; PEEK; lưu localStorage. Từ điển vi/en phủ hết nhãn của các tab này.
|
||||
Exit: mọi tham số đổi tức thì trên preview; reload giữ trạng thái; không còn chuỗi tiếng Anh hardcode ngoài tên film sim.
|
||||
|
||||
### W3 — FRAME / CROP / ROTATE / WATERMARK / RATIO + EXIF GPS
|
||||
4 frame + wall landscape; free crop kéo rect; rotate/straighten/auto; watermark GPS (EXIF + nhập tay) + custom text; photo ratio.
|
||||
Exit: preview đúng layout từng frame; ảnh có GPS tự điền, ảnh không GPS cho nhập tay.
|
||||
|
||||
### W4 — EXPORT / RENDER full-res
|
||||
Worker render full-res + tiến trình + hủy; `encodeToBytes(JPEG, 95)` → `patchJpegDpi(300)` → `writeJpegExif(...)` (port `src/utils/exifWrite.ts`, giữ tag ảnh nguồn, bỏ IFD1/MakerNote, stamp software/date/GPS/dims/dpi); download; thumbnail strip.
|
||||
Exit: file tải về đúng **300 ppi** (`JFIF units=1 X=Y=300`) và **giữ EXIF như app** (camera/lens/ISO của ảnh nguồn + tag app tự ghi); kích thước pixel khớp app với cùng input; cùng ảnh + cùng recipe → EXIF web và app giống nhau từng tag app sở hữu.
|
||||
|
||||
### W5 — API + auth
|
||||
Fastify + SQLite: `POST /api/auth/signup|login|logout`, `GET /api/auth/me`, CRUD `/api/recipes`; session cookie; trang Login/Signup; đồng bộ recipe + favorite; chế độ **demo** (không cần tài khoản, giới hạn theo `tiers.ts`).
|
||||
Exit: đăng ký → đăng nhập → lưu recipe → đăng nhập lại ở máy khác thấy recipe; demo chạy không cần tài khoản và bị giới hạn đúng cấu hình.
|
||||
|
||||
### W6 — Landing page
|
||||
Hero, tính năng, before/after, review (`reviews.json`), badge Google Play/App Store (`storeLinks.ts`), CTA demo + đăng ký; responsive; SEO cơ bản (`<title>`, OG tags, `<html lang>` theo ngôn ngữ đang chọn); landing cũng đổi được theme + ngôn ngữ.
|
||||
Exit: trang mở đẹp ở cả 360px và 1440px ở cả 2 theme; link store + CTA đúng đích; nội dung landing có đủ vi/en.
|
||||
|
||||
### W7 — BATCH EXPORT (add-on)
|
||||
Panel riêng: nạp nhiều ảnh, chọn recipe (bundle hoặc của người dùng), hàng đợi render tuần tự có tiến trình, tải từng file hoặc ZIP. Yêu cầu đăng nhập.
|
||||
Exit: 10 ảnh + 1 recipe → 10 file cùng tông màu, không tràn RAM.
|
||||
|
||||
### W8 — Parity & perf
|
||||
Chạy cùng bộ ảnh trên app (native/Skia) và web; so diff + mắt; tối ưu (worker pool, preview cache, dùng lại bitmap khi export).
|
||||
Exit: diff trong ngưỡng đã chấp nhận ở P6; render 12MP web < ~3s.
|
||||
|
||||
### W9 — Docker + deploy + docs
|
||||
`docker compose up -d --build`; kiểm tra qua `IP:8090`; hướng dẫn gắn Nginx Proxy Manager (domain, TLS, `X-Forwarded-*`); `docker/README.md` (RUNBOOK: port, domain, backup `./data`, đổi `TIER` config); commit + push.
|
||||
Exit: mở webUI từ máy khác trong LAN; đăng ký/đăng nhập/demo/export chạy; container tự lên lại sau reboot.
|
||||
|
||||
**Trạng thái**: `docker/` đã tự chứa và chạy được — `docker/docker-compose.yml` (service `frontend` + `api`), `docker/backend/` (Fastify + SQLite), `docker/frontend/` (Vite + CanvasKit + nginx). Chạy `docker compose up -d --build` trong `docker/`, UI ở `:8090`; đã kiểm chứng health/signup/login/recipes và export ảnh thật end-to-end trong Chromium headless. Còn lại: gắn domain/TLS qua Nginx Proxy Manager, cập nhật `README.md` gốc.
|
||||
|
||||
## Rủi ro
|
||||
- ~~**CanvasKit không có emoji màu**~~ → **đã loại ở W0**: `NotoEmoji-GPS.ttf` cung cấp đủ 📍/📷, không cần fallback Canvas2D.
|
||||
- ~~**Skia API shim** (`MakeMatrixConvolution` khác chữ ký)~~ → **đã loại ở W0**: không tồn tại trong CanvasKit, thay bằng `CLARITY_SKSL`.
|
||||
- ~~**Hiệu năng**: raster CPU chậm gấp ~1000 lần WebGL~~ → **đã chốt**: engine dùng GPU surface, nhưng **một `GrDirectContext` dùng chung** + `MakeRenderTarget` cho mọi surface. `MakeWebGLCanvasSurface` gọi nhiều lần tạo nhiều WebGL context riêng → snapshot của surface này vẽ sang surface kia ra **đen thui** (đo được: 12MP JPEG + output sharpen, GL mean 0.0 vs raster 107.0). Không có WebGL thì fallback raster `MakeSurface` — đúng pipeline, chỉ chậm hơn.
|
||||
- **wasm ~8MB** (build `full`, gzip ~3MB): lazy-load, nginx cache dài hạn.
|
||||
- **RAM 12MP** trong browser (bitmap ~48MB + surface) — giới hạn worker pool 1–2, giải phóng bitmap sau export; batch render tuần tự, không song song.
|
||||
- **HDF/vignette** dùng gradient + blend — kiểm tra tương đương CanvasKit.
|
||||
- Không xoá/đổi code app RN: web chỉ **đọc** `src/utils/*` (nếu phải sửa util để dùng chung → giữ tương thích app, typecheck lại).
|
||||
- Import TS ngoài root `web/`: cấu hình `server.fs.allow` + alias; Docker context phải là repo root.
|
||||
- **Parity chưa chốt**: thiếu cặp ảnh gốc + ảnh app export cùng nguồn (xem cuối W0) → rủi ro phát hiện lệch màu muộn ở W8.
|
||||
- **Metadata**: engine web **phải** giữ đúng 300 ppi và dựng lại EXIF như app; quên bước `writeJpegExif` thì ảnh vẫn ra nhưng mất camera/lens/ISO/ngày chụp.
|
||||
- **Hai đường export của app không đồng nhất**: đường Kotlin native (`RecipescamExportModule.kt`, `Bitmap.compress(JPEG)`) **chỉ** gọi `patchJpegDpi`, **không** dựng lại EXIF → nếu P6 flip default sang native thì app cũng sẽ mất metadata. Cần chốt: port `writeJpegExif` sang Kotlin, hoặc giữ đường Skia làm mặc định. Không ảnh hưởng web, nhưng ảnh hưởng mốc "khớp app".
|
||||
- **Giá trị cần xác nhận khi làm W6**: URL Google Play / App Store thật (đang để placeholder trong `storeLinks.ts`), tên miền cuối cùng gắn qua Nginx Proxy Manager.
|
||||
|
||||
## Checklist
|
||||
- [x] Tạo branch `recipes-web`
|
||||
- [x] Chốt yêu cầu (port, auth, batch, responsive, EXIF GPS)
|
||||
- [x] Bổ sung theme (light/dark + accent) + i18n (vi/en) vào kế hoạch
|
||||
- [x] Chốt metadata export: 300 ppi (`patchJpegDpi`) + dựng lại EXIF (`writeJpegExif`), giữ nguyên kích thước pixel
|
||||
- [ ] W0 spike CanvasKit — pipeline ✓, font ✓, build `full` ✓, WebGL ✓; **còn**: cặp ảnh parity từ người dùng + số end-to-end trên WebGL
|
||||
- [ ] W1 scaffold + drag&drop + responsive
|
||||
- [ ] W2 tool phi cấu trúc ảnh
|
||||
- [ ] W3 frame/crop/rotate/watermark/ratio + EXIF GPS
|
||||
- [ ] W4 export/render full-res
|
||||
- [ ] W5 API + auth + demo
|
||||
- [ ] W6 landing page
|
||||
- [ ] W7 batch export
|
||||
- [ ] W8 parity & perf
|
||||
- [x] `docker/` tự chứa + chạy được (code FE/BE nằm trong `docker/`, compose + Dockerfile + `.env.example` + README) — đã verify end-to-end
|
||||
- [ ] W9 Docker + deploy + docs
|
||||
@@ -92,12 +92,29 @@ android {
|
||||
applicationId 'com.locphamtran.recipescamera'
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 1
|
||||
versionName "1.0.0"
|
||||
versionCode 5
|
||||
versionName "1.2.3"
|
||||
|
||||
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,60 @@
|
||||
{
|
||||
"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.3",
|
||||
"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-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,25 @@
|
||||
# Host port the web UI is published on. Everything else is internal: the API is
|
||||
# only reachable through nginx at /api/.
|
||||
WEB_PORT=8090
|
||||
|
||||
# Comma-separated emails allowed to moderate the landing strip (/admin).
|
||||
# Leave empty to make nobody an admin.
|
||||
ADMIN_EMAILS=
|
||||
|
||||
# The mail relay that sends the address-verification mail — the 6-digit code
|
||||
# plus the link. A new account is a guest until it proves the address with one
|
||||
# of the two, so a deployment without a relay can only ever hand out guest
|
||||
# access.
|
||||
#
|
||||
# Leave SMTP_HOST empty and both the code and the link are written to the api
|
||||
# container's log instead (`docker compose logs api`), which is enough for local
|
||||
# work.
|
||||
SMTP_HOST=
|
||||
# 587 upgrades to TLS (STARTTLS); 465 is TLS from the first byte. Set
|
||||
# SMTP_SECURE=true to force the latter on an unusual port.
|
||||
SMTP_PORT=587
|
||||
SMTP_USER=
|
||||
SMTP_PASS=
|
||||
# What the mail says it is from. Defaults to SMTP_USER, then a noreply address.
|
||||
SMTP_FROM=
|
||||
SMTP_SECURE=
|
||||
@@ -0,0 +1,2 @@
|
||||
.env
|
||||
data/
|
||||
@@ -0,0 +1,91 @@
|
||||
# RecipesCam web — self-contained stack
|
||||
|
||||
A Docker-hosted web build of RecipesCam. Everything it needs is in this folder:
|
||||
move it to another machine, run two commands, and the app is up. It does **not**
|
||||
need the React Native project around it.
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
docker compose up -d --build
|
||||
# → http://localhost:8090
|
||||
```
|
||||
|
||||
## What runs where
|
||||
|
||||
| Service | Image | Role |
|
||||
| --- | --- | --- |
|
||||
| `frontend` | `nginx:1.27-alpine` (built by `frontend/Dockerfile`) | Static SPA + `/api/` reverse proxy |
|
||||
| `api` | `node:22-slim` (built by `backend/Dockerfile`) | Accounts + saved recipes, SQLite on `./data` |
|
||||
|
||||
`frontend` resolves `api` through Docker's embedded DNS and proxies `/api/*` to
|
||||
it — that is why the API container is named `api` and why it is not published on
|
||||
the host. Only `${WEB_PORT:-8090}` is exposed.
|
||||
|
||||
Photos never leave the browser. The CanvasKit render pipeline (grade, frame,
|
||||
watermarks, JPEG encode) runs in the visitor's tab; the API only stores recipes
|
||||
as JSON.
|
||||
|
||||
## Layout
|
||||
|
||||
```
|
||||
docker-compose.yml the stack
|
||||
.env.example WEB_PORT
|
||||
data/ SQLite (created on first run, gitignored)
|
||||
backend/ Fastify + better-sqlite3 API, own Dockerfile
|
||||
frontend/ Vite + React + CanvasKit SPA, own Dockerfile + nginx.conf
|
||||
shared/ vendored copies of the app's types + utils (see below)
|
||||
src/engine/ skiaShim.ts (CanvasKit) + exportEngine.ts (render pipeline)
|
||||
+ session.ts (localStorage/IndexedDB studio persistence)
|
||||
```
|
||||
|
||||
### Vendored files
|
||||
|
||||
`frontend/shared/{types/index.ts,utils/*.ts}` are byte-identical copies of
|
||||
`src/types/index.ts` and ten `src/utils/*.ts` files from the React Native
|
||||
project (`@shopify/react-native-skia` is aliased to `src/engine/skiaShim.ts` in
|
||||
`vite.config.ts` + `tsconfig.json`, so those files compile unchanged):
|
||||
|
||||
`cinemaShader` `colorUtils` `defaultRecipes` `exifWrite` `frameUtils` `jpegDpi`
|
||||
`paramDefs` `recipeShare` `skiaImage` `toneShader`.
|
||||
|
||||
The landing page needs no CDN: `frontend/public/assets/fonts/*.woff2` are the
|
||||
seven self-hosted faces behind the three font groups the Themes menu offers
|
||||
(Plus Jakarta Sans / Inter / JetBrains Mono, Fraunces / Be Vietnam Pro /
|
||||
Courier Prime, Be Vietnam Pro / Space Mono — all SIL OFL, pulled from Google
|
||||
Fonts, vietnamese + latin + latin-ext subsets), and
|
||||
`frontend/public/assets/samples/s*.jpg` are the six placeholder negatives the
|
||||
film strip, preset tester and QR card show (swap them for real graded stills
|
||||
whenever we have them). Its one foreign request is the QR image from
|
||||
`api.qrserver.com`, which degrades to an empty slot offline.
|
||||
|
||||
When the app changes one of them, copy it back in — the renderer is only
|
||||
"parity" for as long as these stay in sync:
|
||||
|
||||
```bash
|
||||
cd <repo>/docker/frontend/shared/utils
|
||||
cp <repo>/src/utils/<name>.ts .
|
||||
```
|
||||
|
||||
## Operations
|
||||
|
||||
```bash
|
||||
docker compose logs -f api # API log
|
||||
docker compose restart api # after backend/src changes (rebuild: --build)
|
||||
docker compose down # stop; ./data survives
|
||||
```
|
||||
|
||||
Backup is the `./data` folder — that is the whole database.
|
||||
|
||||
## Checks
|
||||
|
||||
```bash
|
||||
curl -s http://localhost:8090/api/health # {"ok":true}
|
||||
curl -sI http://localhost:8090/ # 200, index.html
|
||||
```
|
||||
|
||||
Then open the UI, drop a photo in, and confirm the preview shows the picture and
|
||||
EXPORT downloads a JPEG that opens. The preview going solid black while the
|
||||
export still reports a plausible size is the one failure mode worth knowing: it
|
||||
means the CanvasKit GPU surfaces lost their shared `GrDirectContext` (see
|
||||
`frontend/src/engine/skiaShim.ts`), and with no GPU the raster fallback renders
|
||||
the same pipeline correctly, just slower.
|
||||
@@ -0,0 +1,6 @@
|
||||
node_modules
|
||||
dist
|
||||
data
|
||||
*.log
|
||||
Dockerfile
|
||||
.dockerignore
|
||||
@@ -0,0 +1,2 @@
|
||||
node_modules/
|
||||
dist/
|
||||
@@ -0,0 +1,20 @@
|
||||
# RecipesCam API — self-contained build (context = this folder).
|
||||
# Builder compiles TypeScript; runtime carries production deps only.
|
||||
FROM node:22-slim AS builder
|
||||
WORKDIR /app
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci
|
||||
COPY tsconfig.json ./
|
||||
COPY src ./src
|
||||
RUN npm run build && npm prune --omit=dev
|
||||
|
||||
FROM node:22-slim
|
||||
ENV NODE_ENV=production \
|
||||
PORT=3000 \
|
||||
DATA_DIR=/data
|
||||
WORKDIR /app
|
||||
COPY --from=builder /app/node_modules ./node_modules
|
||||
COPY --from=builder /app/dist ./dist
|
||||
COPY package.json ./
|
||||
EXPOSE 3000
|
||||
CMD ["node", "dist/server.js"]
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"name": "recipescam-api",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"description": "RecipesCam web API - accounts + user recipes (server never receives photos)",
|
||||
"main": "dist/server.js",
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"start": "node dist/server.js",
|
||||
"dev": "tsx watch src/server.ts",
|
||||
"test": "node test/security.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"better-sqlite3": "^12.11.1",
|
||||
"fastify": "^5.12.5",
|
||||
"nodemailer": "^7.0.13"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/better-sqlite3": "^9.6.0",
|
||||
"@types/node": "^22.20.3",
|
||||
"@types/nodemailer": "^8.0.2",
|
||||
"tsx": "^4.23.13",
|
||||
"typescript": "^5.9.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import nodemailer, { type Transporter } from 'nodemailer';
|
||||
|
||||
// The API sends exactly one kind of mail: the link that proves an address. The
|
||||
// relay is declared in the deployment's .env, because a mail server is
|
||||
// infrastructure, not a constant.
|
||||
//
|
||||
// With no SMTP_HOST there is nothing to connect to, so the link is written to
|
||||
// the log instead. That keeps a dev box — or this repo's own test suite — able
|
||||
// to finish a signup without a mail server, and whoever reads the log is
|
||||
// already the person running the database.
|
||||
const SMTP_HOST = (process.env.SMTP_HOST ?? '').trim();
|
||||
const SMTP_PORT = Number(process.env.SMTP_PORT || 587);
|
||||
const SMTP_USER = (process.env.SMTP_USER ?? '').trim();
|
||||
const SMTP_PASS = process.env.SMTP_PASS ?? '';
|
||||
const SMTP_FROM = (process.env.SMTP_FROM ?? '').trim() || SMTP_USER || 'no-reply@recipescam.local';
|
||||
// 465 is TLS from the first byte; 587 starts in the clear and upgrades. Only a
|
||||
// port the operator actually chose should be second-guessed.
|
||||
const SMTP_SECURE = process.env.SMTP_SECURE ? process.env.SMTP_SECURE === 'true' : SMTP_PORT === 465;
|
||||
|
||||
export const mailConfigured = SMTP_HOST !== '';
|
||||
|
||||
const transporter: Transporter | null = mailConfigured
|
||||
? nodemailer.createTransport({
|
||||
host: SMTP_HOST,
|
||||
port: SMTP_PORT,
|
||||
secure: SMTP_SECURE,
|
||||
auth: SMTP_USER ? { user: SMTP_USER, pass: SMTP_PASS } : undefined,
|
||||
// A relay that never answers must not hold a request open.
|
||||
connectionTimeout: 10_000,
|
||||
greetingTimeout: 10_000,
|
||||
socketTimeout: 20_000,
|
||||
})
|
||||
: null;
|
||||
|
||||
// Both languages, because the account's language is not known before it exists.
|
||||
// The code comes first and the link second: the code is the one that works
|
||||
// without leaving the page the visitor signed up on, so it is what most of them
|
||||
// will use — but the link stays, for the phone that owns the address, and
|
||||
// because a letter that only carries digits is a letter a spam filter can
|
||||
// decide is a phishing attempt.
|
||||
const body = (url: string, code: string) =>
|
||||
[
|
||||
'RecipesCam — xác thực địa chỉ email / verify your email address',
|
||||
'',
|
||||
`Mã xác thực của bạn / your verification code: ${code}`,
|
||||
'',
|
||||
'Mã có hiệu lực 15 phút. Nhập mã vào trang đăng ký để bật các tính năng PRO.',
|
||||
'The code is valid for 15 minutes. Type it into the signup page to unlock PRO.',
|
||||
'',
|
||||
'Hoặc mở liên kết này / or open this link (hiệu lực 24 giờ / valid 24 hours):',
|
||||
url,
|
||||
'',
|
||||
'Nếu bạn không đăng ký, hãy bỏ qua thư này. / If you did not sign up, ignore this mail.',
|
||||
].join('\r\n');
|
||||
|
||||
// Fire and forget: the account already exists, so a relay that is slow, out of
|
||||
// quota or misconfigured may not fail the signup that asked for it. The owner
|
||||
// can ask for another link from the studio; the operator sees the error here.
|
||||
export function sendVerificationMail(to: string, url: string, code: string, log: (msg: string) => void): void {
|
||||
if (!transporter) {
|
||||
log(`[verify] SMTP not configured — code ${code} and verification link for ${to}: ${url}`);
|
||||
return;
|
||||
}
|
||||
transporter
|
||||
.sendMail({ from: SMTP_FROM, to, subject: `RecipesCam — ${code} is your verification code`, text: body(url, code) })
|
||||
.then(() => log(`[verify] code and link sent to ${to}`))
|
||||
.catch((err: unknown) => log(`[verify] could not mail ${to}: ${String(err)}`));
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
// Place names for a coordinate. The phone app asks the OS geocoder, and a
|
||||
// browser has no twin of that, so the lookup lives here: one HTTP call behind a
|
||||
// cache, and the same naming rule the app uses, so a stamp reads the same on
|
||||
// both.
|
||||
import { findPlace, savePlace } from './db';
|
||||
|
||||
// ~11m at the equator: fixes from the same spot share a cache row.
|
||||
const key = (lat: number, lng: number) => `${lat.toFixed(4)},${lng.toFixed(4)}`;
|
||||
|
||||
// Nominatim (OpenStreetMap). Its usage policy asks for one request per second,
|
||||
// a caller that identifies itself, and answers that are kept — hence the queue
|
||||
// below and the SQLite row every answer leaves behind.
|
||||
const ENDPOINT = 'https://nominatim.openstreetmap.org/reverse';
|
||||
const AGENT = 'RecipesCam/1.2 (https://recipescam.labz.io.vn)';
|
||||
const GAP_MS = 1100;
|
||||
const TIMEOUT_MS = 8000;
|
||||
|
||||
// One lookup at a time, never closer together than GAP_MS.
|
||||
let queue: Promise<unknown> = Promise.resolve();
|
||||
|
||||
// Android answers with the commune in `subregion` and the city in `region`;
|
||||
// Nominatim says the same thing with `suburb` and `city`. Same rule as the app's
|
||||
// localityName: "COMMUNE, CITY", and a name that fills both slots prints once.
|
||||
export function nameOf(address: Record<string, string | undefined>): string | null {
|
||||
const district =
|
||||
address.suburb || address.city_district || address.district || address.quarter || address.neighbourhood || address.county;
|
||||
const city = address.city || address.town || address.municipality || address.village || address.state;
|
||||
const parts = district && district !== city ? [district, city] : [district || city];
|
||||
const name = parts.filter(Boolean).join(', ');
|
||||
return name ? name.toUpperCase() : null;
|
||||
}
|
||||
|
||||
export async function placeName(lat: number, lng: number): Promise<string | null> {
|
||||
const cached = findPlace(key(lat, lng));
|
||||
if (cached !== undefined) return cached || null;
|
||||
|
||||
const call = queue.then(async () => {
|
||||
await new Promise((r) => setTimeout(r, GAP_MS));
|
||||
const url = `${ENDPOINT}?format=jsonv2&zoom=14&addressdetails=1&lat=${lat}&lon=${lng}`;
|
||||
const res = await fetch(url, {
|
||||
headers: { 'user-agent': AGENT, accept: 'application/json' },
|
||||
signal: AbortSignal.timeout(TIMEOUT_MS),
|
||||
});
|
||||
if (!res.ok) throw new Error(`geocoder HTTP ${res.status}`);
|
||||
const body = (await res.json()) as { address?: Record<string, string> };
|
||||
return body.address ? nameOf(body.address) : null;
|
||||
});
|
||||
// The queue must not inherit a rejection, or every later lookup would fail
|
||||
// with it; the caller gets the failure through `call` itself.
|
||||
queue = call.catch(() => undefined);
|
||||
|
||||
try {
|
||||
const name = await call;
|
||||
// Only an answer that came back is worth keeping — a timeout or an outage
|
||||
// must not pin "no name here" on the coordinate for good.
|
||||
savePlace(key(lat, lng), name ?? '');
|
||||
return name;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||