Add CanvasKit web engine spike + W0 findings (recipes-web)
This commit is contained in:
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"name": "recipescam-web",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"canvaskit-wasm": "^0.42.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.20.3",
|
||||
"playwright-core": "^1.63.0",
|
||||
"tsx": "^4.23.13",
|
||||
"typescript": "^7.0.2"
|
||||
},
|
||||
"pnpm": {
|
||||
"onlyBuiltDependencies": [
|
||||
"esbuild"
|
||||
]
|
||||
}
|
||||
}
|
||||
Generated
+556
@@ -0,0 +1,556 @@
|
||||
lockfileVersion: '9.0'
|
||||
|
||||
settings:
|
||||
autoInstallPeers: true
|
||||
excludeLinksFromLockfile: false
|
||||
|
||||
importers:
|
||||
|
||||
.:
|
||||
dependencies:
|
||||
canvaskit-wasm:
|
||||
specifier: ^0.42.0
|
||||
version: 0.42.0
|
||||
devDependencies:
|
||||
'@types/node':
|
||||
specifier: ^22.20.3
|
||||
version: 22.20.3
|
||||
playwright-core:
|
||||
specifier: ^1.63.0
|
||||
version: 1.63.0
|
||||
tsx:
|
||||
specifier: ^4.23.13
|
||||
version: 4.23.13
|
||||
typescript:
|
||||
specifier: ^7.0.2
|
||||
version: 7.0.2
|
||||
|
||||
packages:
|
||||
|
||||
'@esbuild/aix-ppc64@0.28.2':
|
||||
resolution: {integrity: sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [ppc64]
|
||||
os: [aix]
|
||||
|
||||
'@esbuild/android-arm64@0.28.2':
|
||||
resolution: {integrity: sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [android]
|
||||
|
||||
'@esbuild/android-arm@0.28.2':
|
||||
resolution: {integrity: sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm]
|
||||
os: [android]
|
||||
|
||||
'@esbuild/android-x64@0.28.2':
|
||||
resolution: {integrity: sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [android]
|
||||
|
||||
'@esbuild/darwin-arm64@0.28.2':
|
||||
resolution: {integrity: sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@esbuild/darwin-x64@0.28.2':
|
||||
resolution: {integrity: sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@esbuild/freebsd-arm64@0.28.2':
|
||||
resolution: {integrity: sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [freebsd]
|
||||
|
||||
'@esbuild/freebsd-x64@0.28.2':
|
||||
resolution: {integrity: sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [freebsd]
|
||||
|
||||
'@esbuild/linux-arm64@0.28.2':
|
||||
resolution: {integrity: sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-arm@0.28.2':
|
||||
resolution: {integrity: sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-ia32@0.28.2':
|
||||
resolution: {integrity: sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [ia32]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-loong64@0.28.2':
|
||||
resolution: {integrity: sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [loong64]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-mips64el@0.28.2':
|
||||
resolution: {integrity: sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [mips64el]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-ppc64@0.28.2':
|
||||
resolution: {integrity: sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-riscv64@0.28.2':
|
||||
resolution: {integrity: sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-s390x@0.28.2':
|
||||
resolution: {integrity: sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-x64@0.28.2':
|
||||
resolution: {integrity: sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/netbsd-arm64@0.28.2':
|
||||
resolution: {integrity: sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [netbsd]
|
||||
|
||||
'@esbuild/netbsd-x64@0.28.2':
|
||||
resolution: {integrity: sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [netbsd]
|
||||
|
||||
'@esbuild/openbsd-arm64@0.28.2':
|
||||
resolution: {integrity: sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [openbsd]
|
||||
|
||||
'@esbuild/openbsd-x64@0.28.2':
|
||||
resolution: {integrity: sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [openbsd]
|
||||
|
||||
'@esbuild/openharmony-arm64@0.28.2':
|
||||
resolution: {integrity: sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [openharmony]
|
||||
|
||||
'@esbuild/sunos-x64@0.28.2':
|
||||
resolution: {integrity: sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [sunos]
|
||||
|
||||
'@esbuild/win32-arm64@0.28.2':
|
||||
resolution: {integrity: sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
|
||||
'@esbuild/win32-ia32@0.28.2':
|
||||
resolution: {integrity: sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [ia32]
|
||||
os: [win32]
|
||||
|
||||
'@esbuild/win32-x64@0.28.2':
|
||||
resolution: {integrity: sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@types/node@22.20.3':
|
||||
resolution: {integrity: sha512-DZmzkmwHzXrLPAXPyKNDzlIwMMUZCVacoD25ywdy5YTKGbOx/2ld+Q38Im2zJ0vBuZP5Prd3VZutKZyXwkOS8A==}
|
||||
|
||||
'@typescript/typescript-aix-ppc64@7.0.2':
|
||||
resolution: {integrity: sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==}
|
||||
engines: {node: '>=16.20.0'}
|
||||
cpu: [ppc64]
|
||||
os: [aix]
|
||||
|
||||
'@typescript/typescript-darwin-arm64@7.0.2':
|
||||
resolution: {integrity: sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==}
|
||||
engines: {node: '>=16.20.0'}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@typescript/typescript-darwin-x64@7.0.2':
|
||||
resolution: {integrity: sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==}
|
||||
engines: {node: '>=16.20.0'}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@typescript/typescript-freebsd-arm64@7.0.2':
|
||||
resolution: {integrity: sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==}
|
||||
engines: {node: '>=16.20.0'}
|
||||
cpu: [arm64]
|
||||
os: [freebsd]
|
||||
|
||||
'@typescript/typescript-freebsd-x64@7.0.2':
|
||||
resolution: {integrity: sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==}
|
||||
engines: {node: '>=16.20.0'}
|
||||
cpu: [x64]
|
||||
os: [freebsd]
|
||||
|
||||
'@typescript/typescript-linux-arm64@7.0.2':
|
||||
resolution: {integrity: sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==}
|
||||
engines: {node: '>=16.20.0'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
|
||||
'@typescript/typescript-linux-arm@7.0.2':
|
||||
resolution: {integrity: sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==}
|
||||
engines: {node: '>=16.20.0'}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
|
||||
'@typescript/typescript-linux-loong64@7.0.2':
|
||||
resolution: {integrity: sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==}
|
||||
engines: {node: '>=16.20.0'}
|
||||
cpu: [loong64]
|
||||
os: [linux]
|
||||
|
||||
'@typescript/typescript-linux-mips64el@7.0.2':
|
||||
resolution: {integrity: sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==}
|
||||
engines: {node: '>=16.20.0'}
|
||||
cpu: [mips64el]
|
||||
os: [linux]
|
||||
|
||||
'@typescript/typescript-linux-ppc64@7.0.2':
|
||||
resolution: {integrity: sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==}
|
||||
engines: {node: '>=16.20.0'}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
|
||||
'@typescript/typescript-linux-riscv64@7.0.2':
|
||||
resolution: {integrity: sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==}
|
||||
engines: {node: '>=16.20.0'}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
|
||||
'@typescript/typescript-linux-s390x@7.0.2':
|
||||
resolution: {integrity: sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==}
|
||||
engines: {node: '>=16.20.0'}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
|
||||
'@typescript/typescript-linux-x64@7.0.2':
|
||||
resolution: {integrity: sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==}
|
||||
engines: {node: '>=16.20.0'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
|
||||
'@typescript/typescript-netbsd-arm64@7.0.2':
|
||||
resolution: {integrity: sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==}
|
||||
engines: {node: '>=16.20.0'}
|
||||
cpu: [arm64]
|
||||
os: [netbsd]
|
||||
|
||||
'@typescript/typescript-netbsd-x64@7.0.2':
|
||||
resolution: {integrity: sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==}
|
||||
engines: {node: '>=16.20.0'}
|
||||
cpu: [x64]
|
||||
os: [netbsd]
|
||||
|
||||
'@typescript/typescript-openbsd-arm64@7.0.2':
|
||||
resolution: {integrity: sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==}
|
||||
engines: {node: '>=16.20.0'}
|
||||
cpu: [arm64]
|
||||
os: [openbsd]
|
||||
|
||||
'@typescript/typescript-openbsd-x64@7.0.2':
|
||||
resolution: {integrity: sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==}
|
||||
engines: {node: '>=16.20.0'}
|
||||
cpu: [x64]
|
||||
os: [openbsd]
|
||||
|
||||
'@typescript/typescript-sunos-x64@7.0.2':
|
||||
resolution: {integrity: sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==}
|
||||
engines: {node: '>=16.20.0'}
|
||||
cpu: [x64]
|
||||
os: [sunos]
|
||||
|
||||
'@typescript/typescript-win32-arm64@7.0.2':
|
||||
resolution: {integrity: sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==}
|
||||
engines: {node: '>=16.20.0'}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
|
||||
'@typescript/typescript-win32-x64@7.0.2':
|
||||
resolution: {integrity: sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==}
|
||||
engines: {node: '>=16.20.0'}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@webgpu/types@0.1.21':
|
||||
resolution: {integrity: sha512-pUrWq3V5PiSGFLeLxoGqReTZmiiXwY3jRkIG5sLLKjyqNxrwm/04b4nw7LSmGWJcKk59XOM/YRTUwOzo4MMlow==}
|
||||
|
||||
canvaskit-wasm@0.42.0:
|
||||
resolution: {integrity: sha512-N3ikotmzYwDVAcpQKDMptBT2P5pIEWS9ywdmrQA2awwaMF9cxKEFuxZZHv9ay1Em9kDjxDsPtwUzDm1uNj136g==}
|
||||
|
||||
esbuild@0.28.2:
|
||||
resolution: {integrity: sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==}
|
||||
engines: {node: '>=18'}
|
||||
hasBin: true
|
||||
|
||||
fsevents@2.3.3:
|
||||
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
|
||||
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
|
||||
os: [darwin]
|
||||
|
||||
playwright-core@1.63.0:
|
||||
resolution: {integrity: sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==}
|
||||
engines: {node: '>=20'}
|
||||
hasBin: true
|
||||
|
||||
tsx@4.23.13:
|
||||
resolution: {integrity: sha512-BL5MGkRln6aDYhb0xbQlEAGw743BaZYWdbWtdJOBriYJboKgUUYCadFp2/FpBBZquBC/ezNBn7wMMPx7FDZUDw==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
hasBin: true
|
||||
|
||||
typescript@7.0.2:
|
||||
resolution: {integrity: sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==}
|
||||
engines: {node: '>=16.20.0'}
|
||||
hasBin: true
|
||||
|
||||
undici-types@6.21.0:
|
||||
resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
|
||||
|
||||
snapshots:
|
||||
|
||||
'@esbuild/aix-ppc64@0.28.2':
|
||||
optional: true
|
||||
|
||||
'@esbuild/android-arm64@0.28.2':
|
||||
optional: true
|
||||
|
||||
'@esbuild/android-arm@0.28.2':
|
||||
optional: true
|
||||
|
||||
'@esbuild/android-x64@0.28.2':
|
||||
optional: true
|
||||
|
||||
'@esbuild/darwin-arm64@0.28.2':
|
||||
optional: true
|
||||
|
||||
'@esbuild/darwin-x64@0.28.2':
|
||||
optional: true
|
||||
|
||||
'@esbuild/freebsd-arm64@0.28.2':
|
||||
optional: true
|
||||
|
||||
'@esbuild/freebsd-x64@0.28.2':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-arm64@0.28.2':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-arm@0.28.2':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-ia32@0.28.2':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-loong64@0.28.2':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-mips64el@0.28.2':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-ppc64@0.28.2':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-riscv64@0.28.2':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-s390x@0.28.2':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-x64@0.28.2':
|
||||
optional: true
|
||||
|
||||
'@esbuild/netbsd-arm64@0.28.2':
|
||||
optional: true
|
||||
|
||||
'@esbuild/netbsd-x64@0.28.2':
|
||||
optional: true
|
||||
|
||||
'@esbuild/openbsd-arm64@0.28.2':
|
||||
optional: true
|
||||
|
||||
'@esbuild/openbsd-x64@0.28.2':
|
||||
optional: true
|
||||
|
||||
'@esbuild/openharmony-arm64@0.28.2':
|
||||
optional: true
|
||||
|
||||
'@esbuild/sunos-x64@0.28.2':
|
||||
optional: true
|
||||
|
||||
'@esbuild/win32-arm64@0.28.2':
|
||||
optional: true
|
||||
|
||||
'@esbuild/win32-ia32@0.28.2':
|
||||
optional: true
|
||||
|
||||
'@esbuild/win32-x64@0.28.2':
|
||||
optional: true
|
||||
|
||||
'@types/node@22.20.3':
|
||||
dependencies:
|
||||
undici-types: 6.21.0
|
||||
|
||||
'@typescript/typescript-aix-ppc64@7.0.2':
|
||||
optional: true
|
||||
|
||||
'@typescript/typescript-darwin-arm64@7.0.2':
|
||||
optional: true
|
||||
|
||||
'@typescript/typescript-darwin-x64@7.0.2':
|
||||
optional: true
|
||||
|
||||
'@typescript/typescript-freebsd-arm64@7.0.2':
|
||||
optional: true
|
||||
|
||||
'@typescript/typescript-freebsd-x64@7.0.2':
|
||||
optional: true
|
||||
|
||||
'@typescript/typescript-linux-arm64@7.0.2':
|
||||
optional: true
|
||||
|
||||
'@typescript/typescript-linux-arm@7.0.2':
|
||||
optional: true
|
||||
|
||||
'@typescript/typescript-linux-loong64@7.0.2':
|
||||
optional: true
|
||||
|
||||
'@typescript/typescript-linux-mips64el@7.0.2':
|
||||
optional: true
|
||||
|
||||
'@typescript/typescript-linux-ppc64@7.0.2':
|
||||
optional: true
|
||||
|
||||
'@typescript/typescript-linux-riscv64@7.0.2':
|
||||
optional: true
|
||||
|
||||
'@typescript/typescript-linux-s390x@7.0.2':
|
||||
optional: true
|
||||
|
||||
'@typescript/typescript-linux-x64@7.0.2':
|
||||
optional: true
|
||||
|
||||
'@typescript/typescript-netbsd-arm64@7.0.2':
|
||||
optional: true
|
||||
|
||||
'@typescript/typescript-netbsd-x64@7.0.2':
|
||||
optional: true
|
||||
|
||||
'@typescript/typescript-openbsd-arm64@7.0.2':
|
||||
optional: true
|
||||
|
||||
'@typescript/typescript-openbsd-x64@7.0.2':
|
||||
optional: true
|
||||
|
||||
'@typescript/typescript-sunos-x64@7.0.2':
|
||||
optional: true
|
||||
|
||||
'@typescript/typescript-win32-arm64@7.0.2':
|
||||
optional: true
|
||||
|
||||
'@typescript/typescript-win32-x64@7.0.2':
|
||||
optional: true
|
||||
|
||||
'@webgpu/types@0.1.21': {}
|
||||
|
||||
canvaskit-wasm@0.42.0:
|
||||
dependencies:
|
||||
'@webgpu/types': 0.1.21
|
||||
|
||||
esbuild@0.28.2:
|
||||
optionalDependencies:
|
||||
'@esbuild/aix-ppc64': 0.28.2
|
||||
'@esbuild/android-arm': 0.28.2
|
||||
'@esbuild/android-arm64': 0.28.2
|
||||
'@esbuild/android-x64': 0.28.2
|
||||
'@esbuild/darwin-arm64': 0.28.2
|
||||
'@esbuild/darwin-x64': 0.28.2
|
||||
'@esbuild/freebsd-arm64': 0.28.2
|
||||
'@esbuild/freebsd-x64': 0.28.2
|
||||
'@esbuild/linux-arm': 0.28.2
|
||||
'@esbuild/linux-arm64': 0.28.2
|
||||
'@esbuild/linux-ia32': 0.28.2
|
||||
'@esbuild/linux-loong64': 0.28.2
|
||||
'@esbuild/linux-mips64el': 0.28.2
|
||||
'@esbuild/linux-ppc64': 0.28.2
|
||||
'@esbuild/linux-riscv64': 0.28.2
|
||||
'@esbuild/linux-s390x': 0.28.2
|
||||
'@esbuild/linux-x64': 0.28.2
|
||||
'@esbuild/netbsd-arm64': 0.28.2
|
||||
'@esbuild/netbsd-x64': 0.28.2
|
||||
'@esbuild/openbsd-arm64': 0.28.2
|
||||
'@esbuild/openbsd-x64': 0.28.2
|
||||
'@esbuild/openharmony-arm64': 0.28.2
|
||||
'@esbuild/sunos-x64': 0.28.2
|
||||
'@esbuild/win32-arm64': 0.28.2
|
||||
'@esbuild/win32-ia32': 0.28.2
|
||||
'@esbuild/win32-x64': 0.28.2
|
||||
|
||||
fsevents@2.3.3:
|
||||
optional: true
|
||||
|
||||
playwright-core@1.63.0: {}
|
||||
|
||||
tsx@4.23.13:
|
||||
dependencies:
|
||||
esbuild: 0.28.2
|
||||
optionalDependencies:
|
||||
fsevents: 2.3.3
|
||||
|
||||
typescript@7.0.2:
|
||||
optionalDependencies:
|
||||
'@typescript/typescript-aix-ppc64': 7.0.2
|
||||
'@typescript/typescript-darwin-arm64': 7.0.2
|
||||
'@typescript/typescript-darwin-x64': 7.0.2
|
||||
'@typescript/typescript-freebsd-arm64': 7.0.2
|
||||
'@typescript/typescript-freebsd-x64': 7.0.2
|
||||
'@typescript/typescript-linux-arm': 7.0.2
|
||||
'@typescript/typescript-linux-arm64': 7.0.2
|
||||
'@typescript/typescript-linux-loong64': 7.0.2
|
||||
'@typescript/typescript-linux-mips64el': 7.0.2
|
||||
'@typescript/typescript-linux-ppc64': 7.0.2
|
||||
'@typescript/typescript-linux-riscv64': 7.0.2
|
||||
'@typescript/typescript-linux-s390x': 7.0.2
|
||||
'@typescript/typescript-linux-x64': 7.0.2
|
||||
'@typescript/typescript-netbsd-arm64': 7.0.2
|
||||
'@typescript/typescript-netbsd-x64': 7.0.2
|
||||
'@typescript/typescript-openbsd-arm64': 7.0.2
|
||||
'@typescript/typescript-openbsd-x64': 7.0.2
|
||||
'@typescript/typescript-sunos-x64': 7.0.2
|
||||
'@typescript/typescript-win32-arm64': 7.0.2
|
||||
'@typescript/typescript-win32-x64': 7.0.2
|
||||
|
||||
undici-types@6.21.0: {}
|
||||
@@ -0,0 +1,2 @@
|
||||
allowBuilds:
|
||||
esbuild: set this to true or false
|
||||
@@ -0,0 +1,99 @@
|
||||
// Per-step cost of the web pipeline, measured at three sizes. The 12MP run of
|
||||
// spike/w0.ts was unusably slow; this isolates WHICH pass eats the time.
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { initSkia, Skia } from '../src/engine/skiaShim';
|
||||
import { CLARITY_SKSL } from '../../src/utils/toneShader';
|
||||
|
||||
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||
const repo = path.resolve(here, '../..');
|
||||
const SAMPLE = process.env.W0_SAMPLE ?? '/tmp/w0/p2.jpg';
|
||||
|
||||
function time(label: string, fn: () => void): number {
|
||||
const t = performance.now();
|
||||
fn();
|
||||
const ms = performance.now() - t;
|
||||
console.log(` ${label.padEnd(26)} ${ms.toFixed(0)}ms`);
|
||||
return ms;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
await initSkia();
|
||||
const bytes = new Uint8Array(fs.readFileSync(SAMPLE));
|
||||
const src = Skia.Image.MakeImageFromEncoded(bytes);
|
||||
console.log(`source ${src.width()}x${src.height()}`);
|
||||
|
||||
const full = { w: src.width(), h: src.height() };
|
||||
const sizes = [
|
||||
{ name: '1024', w: 1024, h: Math.round((1024 * full.h) / full.w) },
|
||||
{ name: '2048', w: 2048, h: Math.round((2048 * full.h) / full.w) },
|
||||
{ name: 'full', w: full.w, h: full.h },
|
||||
];
|
||||
|
||||
const sharpen = Skia.RuntimeEffect.Make(CLARITY_SKSL);
|
||||
const tone = Skia.RuntimeEffect.Make(
|
||||
'uniform shader src; uniform float a; vec4 main(vec2 p){ vec4 c = src.eval(p); return vec4(c.rgb*(1.0+a), c.a);}'
|
||||
);
|
||||
|
||||
for (const s of sizes) {
|
||||
console.log(`\n[sample downscaled to ${s.w}x${s.h}]`);
|
||||
const surf = Skia.Surface.Make(s.w, s.h);
|
||||
const canvas = surf.getCanvas();
|
||||
const paint = Skia.Paint();
|
||||
time('draw downscaled image', () => {
|
||||
canvas.drawImageRect(
|
||||
src, Skia.XYWHRect(0, 0, full.w, full.h), Skia.XYWHRect(0, 0, s.w, s.h), paint
|
||||
);
|
||||
});
|
||||
|
||||
const shader = src.makeShaderOptions(
|
||||
Skia.TileMode.Clamp, Skia.TileMode.Clamp, Skia.FilterMode.Linear, Skia.MipmapMode.None
|
||||
);
|
||||
const toneShader = tone.makeShaderWithChildren([0.2], [shader]);
|
||||
time('tone skSL pass', () => {
|
||||
const p = Skia.Paint();
|
||||
p.setShader(toneShader);
|
||||
canvas.drawRect(Skia.XYWHRect(0, 0, s.w, s.h), p);
|
||||
p.dispose();
|
||||
});
|
||||
|
||||
time('clarity skSL 5-tap', () => {
|
||||
const snap = surf.makeImageSnapshot();
|
||||
const child = snap.makeShaderOptions(
|
||||
Skia.TileMode.Clamp, Skia.TileMode.Clamp, Skia.FilterMode.Linear, Skia.MipmapMode.None
|
||||
);
|
||||
const sh = sharpen.makeShaderWithChildren([0.3, 1, 1], [child]);
|
||||
const p = Skia.Paint();
|
||||
p.setShader(sh);
|
||||
canvas.drawRect(Skia.XYWHRect(0, 0, s.w, s.h), p);
|
||||
p.dispose(); sh.dispose(); child.dispose(); snap.dispose();
|
||||
});
|
||||
|
||||
time('blur sigma=40 (imagefilter)', () => {
|
||||
const snap = surf.makeImageSnapshot();
|
||||
const p = Skia.Paint();
|
||||
p.setImageFilter(Skia.ImageFilter.MakeBlur(40, 40, Skia.TileMode.Clamp, null));
|
||||
canvas.clear(Skia.Color('transparent'));
|
||||
canvas.drawImage(snap, 0, 0, p);
|
||||
p.dispose(); snap.dispose();
|
||||
});
|
||||
|
||||
time('jpeg q95 encode', () => {
|
||||
const snap = surf.makeImageSnapshot();
|
||||
snap.encodeToBytes(Skia.ImageFormat.JPEG, 95);
|
||||
snap.dispose();
|
||||
});
|
||||
surf.dispose();
|
||||
}
|
||||
|
||||
// Full-res decode cost (what happens once per loaded photo).
|
||||
time('decode 12MP jpeg', () => {
|
||||
Skia.Image.MakeImageFromEncoded(bytes);
|
||||
});
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
// Drives spike/gpu.html in headless Chromium: is a WebGL-backed CanvasKit
|
||||
// surface fast enough to replace the CPU raster path (which measured 3.2s for a
|
||||
// single 12MP tone pass in Node)? Run:
|
||||
// node spike/gpu-bench.mjs
|
||||
import http from 'node:http';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { chromium } from 'playwright-core';
|
||||
|
||||
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||
const webRoot = path.resolve(here, '..');
|
||||
const SAMPLES = process.env.W0_SAMPLES ?? '/tmp/w0';
|
||||
const CK_BIN = path.join(webRoot, 'node_modules/canvaskit-wasm/bin');
|
||||
|
||||
const routes = [
|
||||
{ prefix: '/bin/', dir: CK_BIN },
|
||||
{ prefix: '/samples/', dir: SAMPLES },
|
||||
{ prefix: '/', dir: path.join(webRoot, 'spike') },
|
||||
];
|
||||
const MIME = { '.js': 'text/javascript', '.wasm': 'application/wasm', '.html': 'text/html', '.jpg': 'image/jpeg', '.png': 'image/png' };
|
||||
|
||||
const server = http.createServer((req, res) => {
|
||||
const url = decodeURIComponent(req.url.split('?')[0]);
|
||||
const route = routes.find((r) => url.startsWith(r.prefix));
|
||||
if (!route) { res.writeHead(404).end(); return; }
|
||||
const rel = url.slice(route.prefix.length) || 'gpu.html';
|
||||
const file = path.join(route.dir, rel);
|
||||
fs.readFile(file, (err, data) => {
|
||||
if (err) { res.writeHead(404).end(String(err)); return; }
|
||||
res.writeHead(200, { 'content-type': MIME[path.extname(file)] ?? 'application/octet-stream' });
|
||||
res.end(data);
|
||||
});
|
||||
});
|
||||
|
||||
await new Promise((r) => server.listen(0, '127.0.0.1', r));
|
||||
const port = server.address().port;
|
||||
console.log(`serving on ${port}`);
|
||||
|
||||
const browser = await chromium.launch({
|
||||
// The cached playwright build is chromium-1228 while playwright-core wants
|
||||
// 1243; point straight at the cached binary instead of downloading one.
|
||||
executablePath: process.env.CHROME_PATH ??
|
||||
'/home/locpham/.cache/ms-playwright/chromium-1228/chrome-linux64/chrome',
|
||||
args: ['--use-gl=angle', '--use-angle=swiftshader', '--enable-unsafe-swiftshader', '--no-sandbox'],
|
||||
});
|
||||
const page = await browser.newPage();
|
||||
page.on('console', (m) => console.log('[browser]', m.text()));
|
||||
page.on('pageerror', (e) => console.log('[pageerror]', e.message));
|
||||
await page.goto(`http://127.0.0.1:${port}/gpu.html`, { waitUntil: 'load' });
|
||||
await page.waitForFunction(() => document.title === 'done' || document.title === 'error', null, { timeout: 180000 });
|
||||
console.log(await page.textContent('#out').catch(() => '(no output element)'));
|
||||
const results = await page.evaluate(() => window.__results ?? null);
|
||||
console.log('\njson:', JSON.stringify(results));
|
||||
await browser.close();
|
||||
server.close();
|
||||
@@ -0,0 +1,114 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head><meta charset="utf-8"><title>W0 GPU bench</title></head>
|
||||
<body>
|
||||
<pre id="out">running…</pre>
|
||||
<script src="/bin/full/canvaskit.js"></script>
|
||||
<script>
|
||||
(async () => {
|
||||
const CanvasKitInit = window.CanvasKitInit;
|
||||
|
||||
const log = (s) => { document.getElementById('out').textContent += '\n' + s; };
|
||||
const results = {};
|
||||
|
||||
try {
|
||||
const CK = await CanvasKitInit({ locateFile: (f) => '/bin/full/' + f });
|
||||
|
||||
const img = await fetch('/samples/p2.jpg').then((r) => r.arrayBuffer()).then((b) => new Uint8Array(b));
|
||||
let t0 = performance.now();
|
||||
const src = CK.MakeImageFromEncoded(img);
|
||||
log(`decode 12MP: ${(performance.now() - t0).toFixed(0)}ms ${src.width()}x${src.height()}`);
|
||||
|
||||
const W = src.width();
|
||||
const H = src.height();
|
||||
const sizes = [
|
||||
{ name: '1024', w: 1024, h: Math.round((1024 * H) / W) },
|
||||
{ name: '2048', w: 2048, h: Math.round((2048 * H) / W) },
|
||||
{ name: 'full', w: W, h: H },
|
||||
];
|
||||
|
||||
const tone = CK.RuntimeEffect.Make(
|
||||
'uniform shader src; uniform float a; vec4 main(vec2 p){ vec4 c = src.eval(p); return vec4(c.rgb*(1.0+a), c.a);}'
|
||||
);
|
||||
const sharpen = CK.RuntimeEffect.Make(
|
||||
'uniform shader img; uniform float2 texel; uniform float amount; vec4 main(vec2 p){ vec4 c=img.eval(p); vec4 n=img.eval(p+float2(0.0,-texel.y)); vec4 s=img.eval(p+float2(0.0,texel.y)); vec4 w=img.eval(p+float2(-texel.x,0.0)); vec4 e=img.eval(p+float2(texel.x,0.0)); return vec4(clamp(c.rgb*(1.0+4.0*amount)-amount*(n.rgb+s.rgb+w.rgb+e.rgb),0.0,1.0), c.a);}'
|
||||
);
|
||||
log('runtime effects: tone=' + !!tone + ' sharpen=' + !!sharpen);
|
||||
|
||||
for (const s of sizes) {
|
||||
log(`\n[webgl canvas ${s.w}x${s.h}]`);
|
||||
const canvas = new OffscreenCanvas(s.w, s.h);
|
||||
const gl0 = canvas.getContext('webgl2');
|
||||
const dbg = gl0 && gl0.getExtension('WEBGL_debug_renderer_info');
|
||||
if (dbg) log(' GL: ' + gl0.getParameter(dbg.UNMASKED_RENDERER_WEBGL));
|
||||
t0 = performance.now();
|
||||
const surface = CK.MakeWebGLCanvasSurface(canvas);
|
||||
log(` MakeWebGLCanvasSurface: ${(performance.now() - t0).toFixed(0)}ms ok=${!!surface}`);
|
||||
if (!surface) continue;
|
||||
const cv = surface.getCanvas();
|
||||
results[s.name] = {};
|
||||
|
||||
const shader = src.makeShaderOptions(CK.TileMode.Clamp, CK.TileMode.Clamp, CK.FilterMode.Linear, CK.MipmapMode.None);
|
||||
const toneShader = tone.makeShaderWithChildren([0.2], [shader]);
|
||||
|
||||
const draw = () => {
|
||||
const p = new CK.Paint();
|
||||
p.setShader(toneShader);
|
||||
cv.drawRect(CK.XYWHRect(0, 0, s.w, s.h), p);
|
||||
surface.flush();
|
||||
p.delete();
|
||||
};
|
||||
draw(); // warm-up: shader compile + texture upload
|
||||
const measure = (label, fn) => {
|
||||
fn();
|
||||
const t = performance.now();
|
||||
fn();
|
||||
const ms = performance.now() - t;
|
||||
log(` ${label.padEnd(26)} ${ms.toFixed(0)}ms`);
|
||||
results[s.name][label] = ms;
|
||||
};
|
||||
|
||||
measure('tone skSL pass', draw);
|
||||
|
||||
measure('clarity skSL 5-tap', () => {
|
||||
const snap = surface.makeImageSnapshot();
|
||||
const child = snap.makeShaderOptions(CK.TileMode.Clamp, CK.TileMode.Clamp, CK.FilterMode.Linear, CK.MipmapMode.None);
|
||||
const sh = sharpen.makeShaderWithChildren([0.3, 1, 1], [child]);
|
||||
const p = new CK.Paint();
|
||||
p.setShader(sh);
|
||||
cv.drawRect(CK.XYWHRect(0, 0, s.w, s.h), p);
|
||||
surface.flush();
|
||||
p.delete(); sh.delete(); child.delete(); snap.delete();
|
||||
});
|
||||
|
||||
measure('blur sigma=40', () => {
|
||||
const snap = surface.makeImageSnapshot();
|
||||
const p = new CK.Paint();
|
||||
p.setImageFilter(CK.ImageFilter.MakeBlur(40, 40, CK.TileMode.Clamp, null));
|
||||
cv.clear(CK.Color(0, 0, 0, 0));
|
||||
cv.drawImage(snap, 0, 0, p);
|
||||
surface.flush();
|
||||
p.delete(); snap.delete();
|
||||
});
|
||||
|
||||
let enc = -1;
|
||||
measure('jpeg q95 encode', () => {
|
||||
const snap = surface.makeImageSnapshot();
|
||||
const b = snap.encodeToBytes(CK.ImageFormat.JPEG, 95);
|
||||
enc = b ? b.length : -1;
|
||||
snap.delete();
|
||||
});
|
||||
log(' encoded bytes: ' + enc);
|
||||
surface.delete();
|
||||
}
|
||||
window.__results = results;
|
||||
log('\nDONE');
|
||||
document.title = 'done';
|
||||
} catch (e) {
|
||||
log('ERROR ' + (e && e.stack ? e.stack : e));
|
||||
document.title = 'error';
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,14 @@
|
||||
import CanvasKitInit from 'canvaskit-wasm';
|
||||
const CK = await CanvasKitInit();
|
||||
const has = (o, k) => typeof o?.[k];
|
||||
console.log('CK version', CK.version ?? '?');
|
||||
console.log('ImageFilter keys:', Object.keys(CK.ImageFilter).join(','));
|
||||
console.log('ColorFilter keys:', Object.keys(CK.ColorFilter).join(','));
|
||||
console.log('Shader keys:', Object.keys(CK.Shader).join(','));
|
||||
console.log('Typeface keys:', Object.keys(CK.Typeface).join(','));
|
||||
console.log('Surface keys:', Object.keys(CK.Surface).join(','));
|
||||
console.log('RuntimeEffect keys:', Object.keys(CK.RuntimeEffect).join(','));
|
||||
console.log('Paint proto keys:', Object.getOwnPropertyNames(CK.Paint.prototype).join(','));
|
||||
console.log('Canvas draw*:', Object.getOwnPropertyNames(CK.Canvas.prototype).filter(n=>n.startsWith('draw')).join(','));
|
||||
console.log('hasImageFilter', has(CK,'ImageFilter'), 'hasX', has(CK,'XYWHRect'), 'hasColor', has(CK,'Color'));
|
||||
console.log('MakeBlur', has(CK.ImageFilter,'MakeBlur'), 'MakeCompose', has(CK.ImageFilter,'MakeCompose'), 'MakeMatrixConvolution', has(CK.ImageFilter,'MakeMatrixConvolution'));
|
||||
@@ -0,0 +1,34 @@
|
||||
import CanvasKitInit from 'canvaskit-wasm/full';
|
||||
import fs from 'node:fs';
|
||||
const CK = await CanvasKitInit();
|
||||
console.log('MakeSurface', typeof CK.MakeSurface, 'MakeWebGLSurface', typeof CK.MakeWebGLSurface, 'Font', typeof CK.Font, 'TypefaceFontProvider', typeof CK.TypefaceFontProvider, 'MakeManagedAnimation', typeof CK.MakeManagedAnimation);
|
||||
console.log('top keys sample:', Object.keys(CK).filter(k=>/^Make|Font|Typeface|Paragraph/.test(k)).join(','));
|
||||
const surf = CK.MakeSurface(400, 200);
|
||||
console.log('surface', !!surf);
|
||||
const c = surf.getCanvas();
|
||||
c.clear(CK.Color(255,255,255,255));
|
||||
const p = new CK.Paint();
|
||||
p.setColor(CK.Color(20,20,20,255));
|
||||
const ttf = fs.readFileSync('../assets/Cousine-Regular.ttf');
|
||||
const ab = ttf.buffer.slice(ttf.byteOffset, ttf.byteOffset+ttf.byteLength);
|
||||
const tf = CK.Typeface.MakeFreeTypeFaceFromData(ab);
|
||||
console.log('typeface', !!tf, tf && tf.getGlyphIDs('AB')[0] !== undefined);
|
||||
const font = new CK.Font(tf, 32);
|
||||
c.drawText('RecipesCam ABC 0123', 20, 80, p, font);
|
||||
const emoji = fs.readFileSync('../assets/NotoEmoji-GPS.ttf');
|
||||
const eab = emoji.buffer.slice(emoji.byteOffset, emoji.byteOffset+emoji.byteLength);
|
||||
const etf = CK.Typeface.MakeFreeTypeFaceFromData(eab);
|
||||
console.log('emoji typeface', !!etf);
|
||||
if (etf) { const ef = new CK.Font(etf, 32); c.drawText('\u{1F4CD} \u{1F4F7}', 20, 140, p, ef); }
|
||||
const img = surf.makeImageSnapshot();
|
||||
const bytes = img.encodeToBytes(CK.ImageFormat.JPEG, 95);
|
||||
console.log('jpeg bytes', bytes && bytes.length);
|
||||
fs.writeFileSync('/tmp/ck-text.jpg', Buffer.from(bytes));
|
||||
// runtime effect with child shader
|
||||
const eff = CK.RuntimeEffect.Make(`
|
||||
uniform shader img; uniform float2 texel; uniform float amount;
|
||||
half4 main(float2 p){ half4 c = img.eval(p); half4 n = img.eval(p+float2(0.0,-texel.y)); half4 s = img.eval(p+float2(0.0,texel.y)); half4 w = img.eval(p+float2(-texel.x,0.0)); half4 e = img.eval(p+float2(texel.x,0.0)); return c + amount*(4.0*c-n-s-w-e); }`);
|
||||
console.log('runtime effect', !!eff, eff && eff.getUniformCount?.());
|
||||
const imgSh = img.makeShaderOptions(CK.TileMode.Clamp, CK.TileMode.Clamp, CK.FilterMode.Linear, CK.MipmapMode.None);
|
||||
const sh = eff && eff.makeShaderWithChildren([1/400, 1/200, 1.0], [imgSh]);
|
||||
console.log('shader', !!sh);
|
||||
@@ -0,0 +1,10 @@
|
||||
import CanvasKitInit from 'canvaskit-wasm';
|
||||
const CK = await CanvasKitInit();
|
||||
console.log('ImageFormat', JSON.stringify(CK.ImageFormat));
|
||||
const surf = CK.MakeSurface(64, 64);
|
||||
const c = surf.getCanvas(); c.clear(CK.Color(255,0,0,255));
|
||||
const img = surf.makeImageSnapshot();
|
||||
for (const k of Object.keys(CK.ImageFormat)) {
|
||||
try { const b = img.encodeToBytes(CK.ImageFormat[k], 90); console.log(k, b ? b.length : null); } catch(e){ console.log(k, 'ERR', String(e).slice(0,80)); }
|
||||
}
|
||||
console.log('columns/readPixels', typeof img.readPixels, typeof img.encodeToBytes);
|
||||
@@ -0,0 +1,11 @@
|
||||
import CanvasKitInit from 'canvaskit-wasm/full';
|
||||
import fs from 'node:fs';
|
||||
const CK = await CanvasKitInit();
|
||||
console.log('ImageFormat', JSON.stringify(CK.ImageFormat));
|
||||
const surf = CK.MakeSurface(64, 64);
|
||||
const c = surf.getCanvas(); c.clear(CK.Color(255,0,0,255));
|
||||
const img = surf.makeImageSnapshot();
|
||||
for (const k of Object.keys(CK.ImageFormat)) { try { const b = img.encodeToBytes(CK.ImageFormat[k], 90); console.log(k, b ? b.length : null); } catch(e){ console.log(k,'ERR'); } }
|
||||
const ttf = fs.readFileSync('../assets/Cousine-Regular.ttf');
|
||||
const tf = CK.Typeface.MakeFreeTypeFaceFromData(ttf.buffer.slice(ttf.byteOffset, ttf.byteOffset+ttf.byteLength));
|
||||
console.log('freetype', !!tf, 'drawText', typeof CK.Canvas.prototype.drawText, 'MakeSurface', typeof CK.MakeSurface);
|
||||
@@ -0,0 +1,18 @@
|
||||
import CanvasKitInit from 'canvaskit-wasm/full';
|
||||
import fs from 'node:fs';
|
||||
const CK = await CanvasKitInit();
|
||||
const load = (p) => { const b = fs.readFileSync(p); return CK.Typeface.MakeFreeTypeFaceFromData(b.buffer.slice(b.byteOffset, b.byteOffset+b.byteLength)); };
|
||||
const tf = load('../assets/Cousine-Regular.ttf');
|
||||
const etf = load('../assets/NotoEmoji-GPS.ttf');
|
||||
const s = '📍📷';
|
||||
console.log('cousine glyphs', JSON.stringify(tf.getGlyphIDs(s)));
|
||||
console.log('emoji glyphs ', JSON.stringify(etf.getGlyphIDs(s)));
|
||||
console.log('textWidth', tf.getTextWidth?.('RECIPESCAM', 24), etf.getTextWidth?.('📍', 24));
|
||||
const surf = CK.MakeSurface(200, 100); const c = surf.getCanvas();
|
||||
c.clear(CK.Color(0,0,0,255));
|
||||
const p = new CK.Paint(); p.setColor(CK.Color(255,255,255,255));
|
||||
const f = new CK.Font(etf, 48);
|
||||
c.drawText(s, 10, 60, p, f);
|
||||
const px = surf.makeImageSnapshot().readPixels(0, 0, {width:200,height:100,colorType:CK.ColorType.RGBA_8888,alphaType:CK.AlphaType.Unpremul,colorSpace:CK.ColorSpace.SRGB});
|
||||
let lit = 0; for (let i=0;i<px.length;i+=4) if (px[i]>128) lit++;
|
||||
console.log('lit pixels in emoji draw:', lit);
|
||||
+175
@@ -0,0 +1,175 @@
|
||||
// W0 parity/perf spike: run the real web render pipeline in Node (no browser)
|
||||
// over sample photos and report time + determinism. Usage:
|
||||
// W0_SAMPLES=<dir with .jpg> W0_OUT=<dir> corepack pnpm exec tsx spike/w0.ts
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { initSkia, Skia } from '../src/engine/skiaShim';
|
||||
import { renderPhoto, type RenderAssets } from '../src/engine/exportEngine';
|
||||
import { DEFAULT_ADJUSTMENTS, FILM_SIMS, filmSimToRecipe } from '../../src/utils/defaultRecipes';
|
||||
import type { FrameId, Recipe } from '../../src/types';
|
||||
|
||||
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||
const repo = path.resolve(here, '../..');
|
||||
const SAMPLE_DIR = process.env.W0_SAMPLES ?? '/tmp/w0';
|
||||
const OUT_DIR = process.env.W0_OUT ?? '/tmp/w0-out';
|
||||
|
||||
const ab = (p: string): ArrayBuffer => {
|
||||
const b = fs.readFileSync(p);
|
||||
return b.buffer.slice(b.byteOffset, b.byteOffset + b.byteLength) as ArrayBuffer;
|
||||
};
|
||||
|
||||
const assets: RenderAssets = {
|
||||
textFont: ab(path.join(repo, 'assets/Cousine-Regular.ttf')),
|
||||
emojiFont: ab(path.join(repo, 'assets/NotoEmoji-GPS.ttf')),
|
||||
wallframe: new Uint8Array(fs.readFileSync(path.join(repo, 'wallframe.png'))),
|
||||
};
|
||||
|
||||
// One recipe per film sim + a "everything on" torture case + a cinema grade.
|
||||
const heavy: Recipe = {
|
||||
id: 'w0-heavy',
|
||||
name: 'W0 HEAVY',
|
||||
frameId: 'none',
|
||||
useGeotag: true,
|
||||
baseFilter: 'classic-neg',
|
||||
cinema: 'autumn',
|
||||
adjustments: {
|
||||
...DEFAULT_ADJUSTMENTS,
|
||||
exposure: 0.6,
|
||||
exposureCompensation: 2,
|
||||
contrast: 12,
|
||||
saturation: 8,
|
||||
vibrance: 6,
|
||||
highlight: -4,
|
||||
shadow: 5,
|
||||
temperature: 6100,
|
||||
tint: 3,
|
||||
colorChrome: 'weak',
|
||||
colorChromeBlue: 'weak',
|
||||
denoise: 3,
|
||||
clarity: 4,
|
||||
sharpening: 3,
|
||||
grain: 4,
|
||||
hdf: 4,
|
||||
vignette: 3,
|
||||
},
|
||||
};
|
||||
|
||||
const QUICK = process.env.W0_QUICK === '1';
|
||||
|
||||
const CASES: { label: string; recipe: Recipe; frame: FrameId; extra?: Record<string, unknown> }[] = [
|
||||
...FILM_SIMS.map((s) => ({ label: `sim:${s.id}`, recipe: filmSimToRecipe(s), frame: 'none' as FrameId })),
|
||||
{ label: 'heavy', recipe: heavy, frame: 'none' },
|
||||
{ label: 'heavy+polaroid', recipe: heavy, frame: 'polaroid' },
|
||||
{ label: 'heavy+classic', recipe: heavy, frame: 'classic-white' },
|
||||
{
|
||||
label: 'heavy+wallframe',
|
||||
recipe: heavy,
|
||||
frame: 'wallframe',
|
||||
extra: {
|
||||
gpsWatermark: { x: 0.06, y: 0.7, size: 1.2, showName: true, showTime: true },
|
||||
useGeotag: true,
|
||||
},
|
||||
},
|
||||
{ label: 'heavy+wm-rot90', recipe: heavy, frame: 'none', extra: {
|
||||
watermark: { text: 'RECIPESCAM', x: 0.5, y: 0.9 },
|
||||
watermarkRotation: 90,
|
||||
} },
|
||||
];
|
||||
|
||||
const GPS = {
|
||||
latitude: 21.0285,
|
||||
longitude: 105.8542,
|
||||
locality: 'HÀ NỘI',
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
async function main() {
|
||||
const t0 = performance.now();
|
||||
await initSkia();
|
||||
console.log(`CanvasKit init: ${(performance.now() - t0).toFixed(0)}ms`);
|
||||
|
||||
fs.mkdirSync(OUT_DIR, { recursive: true });
|
||||
const samples = fs.readdirSync(SAMPLE_DIR).filter((f) => /\.jpe?g$/i.test(f)).sort();
|
||||
if (samples.length === 0) throw new Error(`no sample jpgs in ${SAMPLE_DIR}`);
|
||||
console.log(`samples: ${samples.join(', ')}`);
|
||||
|
||||
// Stats on the encoded file: a render that silently produced a blank frame
|
||||
// would still "succeed", so mean/stddev of the decoded output is the cheap
|
||||
// proof it contains a picture.
|
||||
const stats = (bytes: Uint8Array) => {
|
||||
const img = Skia.Image.MakeImageFromEncoded(bytes);
|
||||
if (!img) return 'decode-fail';
|
||||
const w = img.width();
|
||||
const h = img.height();
|
||||
const px = img.readPixels(0, 0, {
|
||||
width: w, height: h, colorType: Skia.ColorType.RGBA_8888,
|
||||
alphaType: Skia.AlphaType.Unpremul, colorSpace: Skia.ColorSpace.SRGB,
|
||||
});
|
||||
img.dispose();
|
||||
if (!px) return 'read-fail';
|
||||
let sum = 0;
|
||||
let sum2 = 0;
|
||||
let n = 0;
|
||||
for (let i = 0; i < px.length; i += 4) {
|
||||
const l = 0.2126 * px[i] + 0.7152 * px[i + 1] + 0.0722 * px[i + 2];
|
||||
sum += l;
|
||||
sum2 += l * l;
|
||||
n++;
|
||||
}
|
||||
const mean = sum / n;
|
||||
return `mean=${mean.toFixed(1)} sd=${Math.sqrt(Math.max(0, sum2 / n - mean * mean)).toFixed(1)}`;
|
||||
};
|
||||
|
||||
const rows: string[] = [];
|
||||
for (const sample of samples) {
|
||||
const source = new Uint8Array(fs.readFileSync(path.join(SAMPLE_DIR, sample)));
|
||||
const base = sample.replace(/\.jpe?g$/i, '');
|
||||
const cases = QUICK ? CASES.slice(0, 2).concat(CASES.filter((x) => /heavy/.test(x.label)).slice(0, 2)) : CASES;
|
||||
for (const c of cases) {
|
||||
const dpi = 300;
|
||||
const input = {
|
||||
source,
|
||||
recipe: c.recipe,
|
||||
frameId: c.frame,
|
||||
useGeotag: (c.extra?.useGeotag as boolean) ?? false,
|
||||
gpsInfo: GPS,
|
||||
options: {
|
||||
dpi,
|
||||
sharpen: true,
|
||||
watermark: (c.extra?.watermark as never) ?? null,
|
||||
watermarkRotation: (c.extra?.watermarkRotation as number) ?? 0,
|
||||
gpsWatermark: (c.extra?.gpsWatermark as never) ?? null,
|
||||
wallframeLandscape: false,
|
||||
},
|
||||
assets,
|
||||
};
|
||||
const t = performance.now();
|
||||
const out = await renderPhoto(input);
|
||||
const ms = performance.now() - t;
|
||||
if (!out) {
|
||||
console.log(`FAIL ${sample} ${c.label}`);
|
||||
continue;
|
||||
}
|
||||
const file = path.join(OUT_DIR, `${base}__${c.label.replace(/[^a-z0-9+]/gi, '_')}.jpg`);
|
||||
fs.writeFileSync(file, out.bytes);
|
||||
const jfif = new DataView(out.bytes.buffer, out.bytes.byteOffset).getUint16(14, false);
|
||||
rows.push(`${sample}\t${c.label}\t${out.width}x${out.height}\t${(out.bytes.length / 1024).toFixed(0)}KB\t${ms.toFixed(0)}ms\tdpi=${jfif}\t${stats(out.bytes)}`);
|
||||
}
|
||||
}
|
||||
console.log('\nsample\tcase\tsize\tencoded\ttime\tdpi');
|
||||
console.log(rows.join('\n'));
|
||||
|
||||
// Determinism: the same input twice must produce identical bytes (a renderer
|
||||
// that is stable in Node is the baseline the browser worker must match).
|
||||
const source = new Uint8Array(fs.readFileSync(path.join(SAMPLE_DIR, samples[0])));
|
||||
const args = { source, recipe: heavy, frameId: 'none' as FrameId, assets };
|
||||
const a = await renderPhoto(args);
|
||||
const b = await renderPhoto(args);
|
||||
console.log('\ndeterministic:', !!a && !!b && Buffer.compare(Buffer.from(a.bytes), Buffer.from(b.bytes)) === 0);
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,690 @@
|
||||
// Full-resolution render pipeline for the web build.
|
||||
//
|
||||
// Port of `src/utils/exportEngine.ts` (React Native / Skia JSI) onto CanvasKit,
|
||||
// step for step, so the file a browser downloads matches the file the phone
|
||||
// writes. What changed and why (W0 probe, measured — see skiaShim.ts header):
|
||||
// - `Skia.Data.fromURI` + expo file APIs → the caller hands in the JPEG bytes
|
||||
// and gets JPEG bytes back; the file save / gallery / MediaLibrary tail is
|
||||
// gone (the browser downloads a Blob instead).
|
||||
// - `MakeMatrixConvolution` does not exist in CanvasKit. The two convolution
|
||||
// steps (CLARITY, output sharpen) run through `CLARITY_SKSL` from
|
||||
// `toneShader.ts` — the same kernel `[0,-a,0,-a,1+4a,-a,0,-a,0]`, evaluated
|
||||
// as a shader pass. `convolve` replaces the pixels outright (the shader's
|
||||
// output is opaque and covers the same rect), so no clearing pass is needed.
|
||||
// - The negative-side blurs stay on `ImageFilter.MakeBlur`, but a blur cannot
|
||||
// be composited over the un-blurred content, so those passes snapshot, clear
|
||||
// and redraw. Order matches RN's nested filter chain: denoise → clarity →
|
||||
// mist → soften.
|
||||
// - EXIF rewrite (`writeJpegExif`) is not ported yet; `patchJpegDpi(300)`
|
||||
// alone keeps the print size metadata.
|
||||
// ponytail: EXIF says "W4 if the web export needs the camera/lens tags".
|
||||
|
||||
import { Skia } from './skiaShim';
|
||||
import type { Recipe, GPSInfo, FrameId, AspectRatio, CropRect } from '../../../src/types';
|
||||
import { ASPECT_RATIO_W_H } from '../../../src/types';
|
||||
import { getSkiaColorMatrix, applyExposureGain } from '../../../src/utils/colorUtils';
|
||||
import {
|
||||
TONE_SKSL,
|
||||
GLOW_SKSL,
|
||||
CLARITY_SKSL,
|
||||
getToneUniforms,
|
||||
toneIsActive,
|
||||
toneUniformArray,
|
||||
glowUniformArray,
|
||||
} from '../../../src/utils/toneShader';
|
||||
import { CINEMA_SKSL, getCinemaUniforms, cinemaIsActive } from '../../../src/utils/cinemaShader';
|
||||
import {
|
||||
drawFrameOnCanvas,
|
||||
polaroidLayout,
|
||||
POLAROID_CARD,
|
||||
POLAROID_WIN_W,
|
||||
wallframeLayout,
|
||||
WALLFRAME_W,
|
||||
WALLFRAME_H,
|
||||
WALLFRAME_LAND_W,
|
||||
WALLFRAME_LAND_H,
|
||||
} from '../../../src/utils/frameUtils';
|
||||
import { patchJpegDpi } from '../../../src/utils/jpegDpi';
|
||||
import { applyPhotoRotation } from '../../../src/utils/skiaImage';
|
||||
|
||||
export interface RenderAssets {
|
||||
textFont: ArrayBuffer; // assets/Cousine-Regular.ttf
|
||||
emojiFont: ArrayBuffer; // assets/NotoEmoji-GPS.ttf
|
||||
wallframe: Uint8Array; // wallframe.png
|
||||
}
|
||||
|
||||
export interface RenderOptions {
|
||||
dpi?: number;
|
||||
sharpen?: boolean;
|
||||
evFromCamera?: boolean;
|
||||
aspect?: AspectRatio;
|
||||
cropWH?: number;
|
||||
cropRect?: CropRect | null;
|
||||
watermark?: { text: string; x: number; y: number; color?: string; size?: number; font?: string | null } | null;
|
||||
liteMark?: { text: string; x: number; y: number; color?: string; size?: number; font?: string | null } | null;
|
||||
watermarkRotation?: number;
|
||||
gpsWatermark?: { x: number; y: number; size: number; color?: string; showName?: boolean; showTime?: boolean } | null;
|
||||
wallframeLandscape?: boolean;
|
||||
frameWindowZoom?: { s: number; u: number; v: number } | null;
|
||||
photoRotation?: 0 | 90 | 180 | 270;
|
||||
photoStraighten?: number;
|
||||
}
|
||||
|
||||
export interface RenderInput {
|
||||
source: Uint8Array;
|
||||
recipe: Recipe;
|
||||
frameId: FrameId;
|
||||
useGeotag?: boolean;
|
||||
gpsInfo?: GPSInfo | null;
|
||||
options?: RenderOptions;
|
||||
assets: RenderAssets;
|
||||
}
|
||||
|
||||
export interface RenderResult {
|
||||
bytes: Uint8Array;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
type SkDisposable = { dispose(): void };
|
||||
|
||||
function disposeAll(items: SkDisposable[]): void {
|
||||
for (let i = items.length - 1; i >= 0; i--) {
|
||||
try {
|
||||
items[i].dispose();
|
||||
} catch {
|
||||
/* already released */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function release(owned: SkDisposable[], item: SkDisposable | null): void {
|
||||
if (!item) return;
|
||||
const i = owned.indexOf(item);
|
||||
if (i >= 0) owned.splice(i, 1);
|
||||
disposeAll([item]);
|
||||
}
|
||||
|
||||
function createSurface(width: number, height: number) {
|
||||
return Skia.Surface.MakeOffscreen(width, height) ?? Skia.Surface.Make(width, height);
|
||||
}
|
||||
|
||||
// A WebGL surface batches draws; the readback paths (snapshot, encode) must see
|
||||
// them submitted first. A no-op on the raster fallback.
|
||||
function flush(surface: any): void {
|
||||
if (typeof surface?.flush === 'function') surface.flush();
|
||||
}
|
||||
|
||||
// --- shared runtime effects -------------------------------------------------
|
||||
|
||||
let sharpenEffect: any = null;
|
||||
let toneEffect: any = null;
|
||||
let cinemaEffect: any = null;
|
||||
let glowEffect: any = null;
|
||||
|
||||
function effects() {
|
||||
if (!sharpenEffect) sharpenEffect = Skia.RuntimeEffect.Make(CLARITY_SKSL);
|
||||
if (!toneEffect) toneEffect = Skia.RuntimeEffect.Make(TONE_SKSL);
|
||||
if (!cinemaEffect) cinemaEffect = Skia.RuntimeEffect.Make(CINEMA_SKSL);
|
||||
if (!glowEffect) glowEffect = Skia.RuntimeEffect.Make(GLOW_SKSL);
|
||||
return { sharpenEffect, toneEffect, cinemaEffect, glowEffect };
|
||||
}
|
||||
|
||||
// CLARITY_SKSL uniforms are (a, px.x, px.y); px = one source pixel = 1 unit on
|
||||
// a 1:1 export canvas, so the radius matches what the preview tuned.
|
||||
function convolvePaint(srcImage: any, amount: number): any {
|
||||
const { sharpenEffect: effect } = effects();
|
||||
if (!effect) return null;
|
||||
const paint = Skia.Paint();
|
||||
const child = srcImage.makeShaderOptions(
|
||||
Skia.TileMode.Clamp, Skia.TileMode.Clamp, Skia.FilterMode.Linear, Skia.MipmapMode.None
|
||||
);
|
||||
const shader = effect.makeShaderWithChildren([amount, 1, 1], [child]);
|
||||
if (!shader) {
|
||||
paint.dispose();
|
||||
child.dispose();
|
||||
return null;
|
||||
}
|
||||
paint.setShader(shader);
|
||||
return { paint, shader, child };
|
||||
}
|
||||
|
||||
// The convolution must sample the ALREADY GRADED pixels (RN runs it as the
|
||||
// outermost filter of the draw), so the pass reads a snapshot of the surface
|
||||
// and writes the sharpened result back over the same rect.
|
||||
function convPass(canvas: any, surface: any, w: number, h: number, amount: number): void {
|
||||
flush(surface);
|
||||
const snap = surface.makeImageSnapshot();
|
||||
if (!snap) return;
|
||||
drawConvolved(canvas, snap, w, h, amount);
|
||||
snap.dispose();
|
||||
}
|
||||
|
||||
// Replaces the pixels under the rect with the convolved result.
|
||||
function drawConvolved(canvas: any, srcImage: any, w: number, h: number, amount: number): void {
|
||||
const built = convolvePaint(srcImage, amount);
|
||||
if (!built) return;
|
||||
canvas.drawRect(Skia.XYWHRect(0, 0, w, h), built.paint);
|
||||
disposeAll([built.paint, built.shader, built.child]);
|
||||
}
|
||||
|
||||
function convolveImage(image: any, amount: number): any {
|
||||
const w = image.width();
|
||||
const h = image.height();
|
||||
const surf = createSurface(w, h);
|
||||
if (!surf) return image;
|
||||
try {
|
||||
drawConvolved(surf.getCanvas(), image, w, h, amount);
|
||||
return surf.makeImageSnapshot() ?? image;
|
||||
} finally {
|
||||
surf.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
function screenSharpenImage(image: any, amount = 0.5): any {
|
||||
return convolveImage(image, amount);
|
||||
}
|
||||
|
||||
// Snapshot → clear → blur redraw: the blur must not sit on top of the sharp
|
||||
// pixels it is meant to replace.
|
||||
function drawBlurred(canvas: any, surface: any, w: number, h: number, sigma: number): void {
|
||||
flush(surface);
|
||||
const snap = surface.makeImageSnapshot();
|
||||
const paint = Skia.Paint();
|
||||
const filter = Skia.ImageFilter.MakeBlur(sigma, sigma, Skia.TileMode.Clamp, null);
|
||||
paint.setImageFilter(filter);
|
||||
canvas.clear(Skia.Color('transparent'));
|
||||
canvas.drawImage(snap, 0, 0, paint);
|
||||
disposeAll([paint, filter, snap]);
|
||||
}
|
||||
|
||||
// --- fonts / artwork --------------------------------------------------------
|
||||
|
||||
let faces: { typeface: any; iconTypeface: any } | null = null;
|
||||
let wallframeImage: any = null;
|
||||
|
||||
function loadFaces(assets: RenderAssets) {
|
||||
if (faces) return faces;
|
||||
const typeface = Skia.Typeface.MakeFreeTypeFaceFromData(assets.textFont);
|
||||
const iconTypeface = Skia.Typeface.MakeFreeTypeFaceFromData(assets.emojiFont);
|
||||
if (!typeface || !iconTypeface) return null;
|
||||
faces = { typeface, iconTypeface };
|
||||
// Named-family lookup for the custom watermark's FONT option: register the
|
||||
// bundled face so at least that one name resolves on web.
|
||||
try {
|
||||
Skia.FontMgr.System().registerFace(typeface, 'Cousine');
|
||||
} catch {
|
||||
/* provider optional */
|
||||
}
|
||||
return faces;
|
||||
}
|
||||
|
||||
function loadWallframe(assets: RenderAssets) {
|
||||
if (wallframeImage) return wallframeImage;
|
||||
wallframeImage = Skia.Image.MakeImageFromEncoded(assets.wallframe);
|
||||
return wallframeImage;
|
||||
}
|
||||
|
||||
export async function renderPhoto(input: RenderInput): Promise<RenderResult | null> {
|
||||
const { recipe, frameId, options } = input;
|
||||
const owned: SkDisposable[] = [];
|
||||
const own = <T,>(obj: T | null | undefined): T => {
|
||||
if (obj) owned.push(obj as unknown as SkDisposable);
|
||||
return obj as T;
|
||||
};
|
||||
try {
|
||||
const skImage0 = Skia.Image.MakeImageFromEncoded(input.source);
|
||||
if (!skImage0) {
|
||||
console.error('Failed to parse source image');
|
||||
return null;
|
||||
}
|
||||
own(skImage0);
|
||||
let skImage = skImage0;
|
||||
|
||||
const rotated = applyPhotoRotation(skImage, options?.photoRotation, options?.photoStraighten);
|
||||
if (rotated && rotated !== skImage) {
|
||||
own(rotated);
|
||||
release(owned, skImage);
|
||||
skImage = rotated;
|
||||
}
|
||||
let width = skImage.width();
|
||||
let height = skImage.height();
|
||||
|
||||
// 1b. Composition / FRAME-tab crop — identical maths to the RN engine.
|
||||
const aspectWH = options?.aspect && options.aspect !== 'full' ? ASPECT_RATIO_W_H[options.aspect] : null;
|
||||
const cropRect = options?.cropRect ?? null;
|
||||
const ratioWH = cropRect ? null : options?.cropWH ?? aspectWH;
|
||||
if ((cropRect || ratioWH) && frameId !== 'wallframe') {
|
||||
const curAspect = width / height;
|
||||
let cropW = width;
|
||||
let cropH = height;
|
||||
let srcX = 0;
|
||||
let srcY = 0;
|
||||
if (cropRect) {
|
||||
srcX = Math.max(0, Math.min(width - 1, Math.round(cropRect.x * width)));
|
||||
srcY = Math.max(0, Math.min(height - 1, Math.round(cropRect.y * height)));
|
||||
cropW = Math.max(1, Math.min(width - srcX, Math.round(cropRect.w * width)));
|
||||
cropH = Math.max(1, Math.min(height - srcY, Math.round(cropRect.h * height)));
|
||||
} else if (curAspect > (ratioWH as number)) {
|
||||
cropW = Math.round(height * (ratioWH as number));
|
||||
srcX = Math.round((width - cropW) / 2);
|
||||
} else {
|
||||
cropH = Math.round(width / (ratioWH as number));
|
||||
srcY = Math.round((height - cropH) / 2);
|
||||
}
|
||||
const cropSurface = createSurface(cropW, cropH);
|
||||
if (!cropSurface) return null;
|
||||
own(cropSurface);
|
||||
cropSurface.getCanvas().drawImageRect(
|
||||
skImage,
|
||||
Skia.XYWHRect(srcX, srcY, cropW, cropH),
|
||||
Skia.XYWHRect(0, 0, cropW, cropH),
|
||||
own(Skia.Paint())
|
||||
);
|
||||
const cropped = cropSurface.makeImageSnapshot();
|
||||
if (!cropped) return null;
|
||||
own(cropped);
|
||||
release(owned, skImage);
|
||||
release(owned, cropSurface);
|
||||
skImage = cropped;
|
||||
width = cropW;
|
||||
height = cropH;
|
||||
}
|
||||
|
||||
// 2. Render surface.
|
||||
const surface = createSurface(width, height);
|
||||
if (!surface) return null;
|
||||
own(surface);
|
||||
const canvas = surface.getCanvas();
|
||||
const paint = own(Skia.Paint());
|
||||
|
||||
const adjustments = recipe.adjustments;
|
||||
|
||||
// 3. Colour matrix (+2^EV gain unless the camera already biased the shot).
|
||||
const evStops = adjustments.exposureCompensation ?? 0;
|
||||
const evMatrix = options?.evFromCamera
|
||||
? getSkiaColorMatrix(recipe.baseFilter, adjustments)
|
||||
: applyExposureGain(getSkiaColorMatrix(recipe.baseFilter, adjustments), evStops);
|
||||
const colorFilter = own(Skia.ColorFilter.MakeMatrix(evMatrix));
|
||||
paint.setColorFilter(colorFilter);
|
||||
|
||||
// 3b. Tone shader.
|
||||
const tone = getToneUniforms(adjustments, recipe.baseFilter);
|
||||
let toneShader: any = null;
|
||||
if (toneIsActive(tone)) {
|
||||
const { toneEffect: effect } = effects();
|
||||
if (effect) {
|
||||
const imageShader = own(
|
||||
skImage.makeShaderOptions(
|
||||
Skia.TileMode.Clamp, Skia.TileMode.Clamp, Skia.FilterMode.Linear, Skia.MipmapMode.None
|
||||
)
|
||||
);
|
||||
toneShader = own(effect.makeShaderWithChildren(toneUniformArray(tone), [imageShader]));
|
||||
if (toneShader) paint.setShader(toneShader);
|
||||
}
|
||||
}
|
||||
|
||||
// 3c. Cinema seasonal grade (cinema → tone → image).
|
||||
const cinema = getCinemaUniforms(recipe.cinema);
|
||||
let paintShader = toneShader;
|
||||
if (cinema && cinemaIsActive(cinema.flat)) {
|
||||
const { cinemaEffect: effect } = effects();
|
||||
if (effect) {
|
||||
const child =
|
||||
toneShader ??
|
||||
own(skImage.makeShaderOptions(
|
||||
Skia.TileMode.Clamp, Skia.TileMode.Clamp, Skia.FilterMode.Linear, Skia.MipmapMode.None
|
||||
));
|
||||
const cs = own(effect.makeShaderWithChildren(cinema.flat, [child]));
|
||||
if (cs) {
|
||||
paintShader = cs;
|
||||
paint.setShader(paintShader);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Denoise (blur only — CLARITY moved to its own pass below, see header).
|
||||
if (adjustments.denoise > 0) {
|
||||
const sigma = (adjustments.denoise / 10) * 0.6;
|
||||
paint.setImageFilter(own(Skia.ImageFilter.MakeBlur(sigma, sigma, Skia.TileMode.Clamp, null)));
|
||||
}
|
||||
|
||||
// 5. Draw the graded photo.
|
||||
if (paintShader) {
|
||||
canvas.drawRect(Skia.XYWHRect(0, 0, width, height), paint);
|
||||
} else {
|
||||
canvas.drawImage(skImage, 0, 0, paint);
|
||||
}
|
||||
|
||||
// 4b. CLARITY (positive) — convolution outside the denoise blur, the same
|
||||
// nesting RN builds with MakeMatrixConvolution(input = previous filter).
|
||||
if (adjustments.clarity > 0) {
|
||||
convPass(canvas, surface, width, height, (adjustments.clarity / 10) * 0.8);
|
||||
} else if (adjustments.clarity < 0) {
|
||||
const mistSigma = Math.abs(adjustments.clarity / 10) * 4;
|
||||
drawBlurred(canvas, surface, width, height, mistSigma);
|
||||
}
|
||||
// Negative SHARPENING takes edge enhancement back out — after the conv, so
|
||||
// it cannot blur away what the CLARITY pass just added.
|
||||
const sharpKnob = adjustments.sharpening ?? 0;
|
||||
if (sharpKnob < 0) {
|
||||
drawBlurred(canvas, surface, width, height, (Math.abs(sharpKnob) / 10) * 3);
|
||||
}
|
||||
|
||||
// 5b. HDF bright-pass bloom.
|
||||
if ((adjustments.hdf ?? 0) > 0) {
|
||||
const strength = (adjustments.hdf ?? 0) / 10;
|
||||
const sigma = width * (0.004 + 0.015 * strength);
|
||||
const { glowEffect: effect } = effects();
|
||||
const srcShader =
|
||||
paintShader ??
|
||||
own(skImage.makeShaderOptions(
|
||||
Skia.TileMode.Clamp, Skia.TileMode.Clamp, Skia.FilterMode.Linear, Skia.MipmapMode.None
|
||||
));
|
||||
const glowShader =
|
||||
effect != null ? own(effect.makeShaderWithChildren(glowUniformArray(), [srcShader])) : null;
|
||||
const glowPaint = own(Skia.Paint());
|
||||
glowPaint.setBlendMode(Skia.BlendMode.Screen);
|
||||
glowPaint.setAlphaf(0.15 + 0.35 * strength);
|
||||
if (glowShader != null) {
|
||||
glowPaint.setShader(glowShader);
|
||||
glowPaint.setImageFilter(
|
||||
own(Skia.ImageFilter.MakeCompose(
|
||||
own(Skia.ImageFilter.MakeBlur(sigma, sigma, Skia.TileMode.Clamp, null)),
|
||||
own(Skia.ImageFilter.MakeColorFilter(colorFilter, null))
|
||||
))
|
||||
);
|
||||
canvas.drawRect(Skia.XYWHRect(0, 0, width, height), glowPaint);
|
||||
} else {
|
||||
const hiMatrix = [
|
||||
2.5, 0, 0, 0, -1.5,
|
||||
0, 2.5, 0, 0, -1.5,
|
||||
0, 0, 2.5, 0, -1.5,
|
||||
0, 0, 0, 1, 0,
|
||||
];
|
||||
glowPaint.setImageFilter(
|
||||
own(Skia.ImageFilter.MakeCompose(
|
||||
own(Skia.ImageFilter.MakeBlur(sigma, sigma, Skia.TileMode.Clamp, null)),
|
||||
own(Skia.ImageFilter.MakeColorFilter(
|
||||
own(Skia.ColorFilter.MakeCompose(
|
||||
own(Skia.ColorFilter.MakeMatrix(hiMatrix)),
|
||||
colorFilter
|
||||
)),
|
||||
null
|
||||
))
|
||||
))
|
||||
);
|
||||
if (paintShader) {
|
||||
glowPaint.setShader(paintShader);
|
||||
canvas.drawRect(Skia.XYWHRect(0, 0, width, height), glowPaint);
|
||||
} else {
|
||||
canvas.drawImage(skImage, 0, 0, glowPaint);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 6. Monochrome grain overlay (negative NR re-grains, same ceiling as GRAIN).
|
||||
const grainAmount = Math.min(
|
||||
10,
|
||||
Math.max(0, adjustments.grain) + Math.max(0, -(adjustments.denoise ?? 0))
|
||||
);
|
||||
if (grainAmount > 0) {
|
||||
const grainPaint = own(Skia.Paint());
|
||||
grainPaint.setBlendMode(Skia.BlendMode.Overlay);
|
||||
grainPaint.setAlphaf(grainAmount / 20);
|
||||
const noiseEffect = own(Skia.RuntimeEffect.Make(`
|
||||
uniform float u;
|
||||
float grainHash(vec2 q) {
|
||||
return fract(sin(dot(q, vec2(12.9898, 78.233))) * 43758.5453);
|
||||
}
|
||||
vec4 main(vec2 pos) {
|
||||
vec2 p = pos.xy / max(u, 1.0);
|
||||
vec2 i = floor(p);
|
||||
vec2 f = p - i;
|
||||
f = f * f * (3.0 - 2.0 * f);
|
||||
float g00 = grainHash(i);
|
||||
float g10 = grainHash(i + vec2(1.0, 0.0));
|
||||
float g01 = grainHash(i + vec2(0.0, 1.0));
|
||||
float g11 = grainHash(i + vec2(1.0, 1.0));
|
||||
return vec4(vec3(mix(mix(g00, g10, f.x), mix(g01, g11, f.x), f.y)), 1.0);
|
||||
}
|
||||
`));
|
||||
const noiseShader = noiseEffect ? own(noiseEffect.makeShader([width / 1080])) : null;
|
||||
if (noiseShader) {
|
||||
grainPaint.setShader(noiseShader);
|
||||
canvas.drawRect(Skia.XYWHRect(0, 0, width, height), grainPaint);
|
||||
}
|
||||
}
|
||||
|
||||
// 6b. Vignette.
|
||||
if ((adjustments.vignette ?? 0) > 0) {
|
||||
const v = Math.min(10, adjustments.vignette ?? 0) / 10;
|
||||
const vignetteShader = own(Skia.Shader.MakeRadialGradient(
|
||||
Skia.Point(width / 2, height / 2),
|
||||
Math.hypot(width, height) / 2,
|
||||
[Skia.Color('rgba(0,0,0,0)'), Skia.Color('rgba(0,0,0,0)'), Skia.Color('rgba(0,0,0,1)')],
|
||||
[0, 0.45, 1],
|
||||
Skia.TileMode.Clamp
|
||||
));
|
||||
if (vignetteShader) {
|
||||
const vignettePaint = own(Skia.Paint());
|
||||
vignettePaint.setShader(vignetteShader);
|
||||
vignettePaint.setAlphaf(0.9 * v);
|
||||
canvas.drawRect(Skia.XYWHRect(0, 0, width, height), vignettePaint);
|
||||
}
|
||||
}
|
||||
|
||||
release(owned, skImage);
|
||||
|
||||
// 7. Frame.
|
||||
let outputSurface = surface;
|
||||
let stampCanvas = canvas;
|
||||
let stampW = width;
|
||||
let stampH = height;
|
||||
let cardSource: any = null;
|
||||
if (frameId === 'polaroid') {
|
||||
flush(surface);
|
||||
const photoImg = own(surface.makeImageSnapshot());
|
||||
cardSource = photoImg;
|
||||
const outW = width;
|
||||
const frameAspect =
|
||||
ratioWH ??
|
||||
(options?.photoRotation === 90 || options?.photoRotation === 270
|
||||
? height / width
|
||||
: width / height);
|
||||
const outH = Math.round(
|
||||
width * (POLAROID_CARD.sideMargin + POLAROID_CARD.bottomDeck) +
|
||||
(width * POLAROID_WIN_W) / frameAspect
|
||||
);
|
||||
const cardSurface = createSurface(outW, outH);
|
||||
if (!cardSurface) return null;
|
||||
own(cardSurface);
|
||||
const cardCanvas = cardSurface.getCanvas();
|
||||
const bgPaint = own(Skia.Paint());
|
||||
bgPaint.setColor(Skia.Color('#faf9f6'));
|
||||
cardCanvas.drawRect(Skia.XYWHRect(0, 0, outW, outH), bgPaint);
|
||||
const pl = polaroidLayout(outW, outH, frameAspect, 'contain');
|
||||
const win = pl.window;
|
||||
const plainPaint = own(Skia.Paint());
|
||||
const pz = options?.frameWindowZoom ?? null;
|
||||
const pwz = pz && pz.s > 1.001 ? pz : null;
|
||||
const pVisW = pwz ? width / pwz.s : width;
|
||||
const pVisH = pwz ? height / pwz.s : height;
|
||||
const pSx = pwz ? Math.min(Math.max(width * pwz.u - pVisW / 2, 0), width - pVisW) : 0;
|
||||
const pSy = pwz ? Math.min(Math.max(height * pwz.v - pVisH / 2, 0), height - pVisH) : 0;
|
||||
const srcAspect = width / height;
|
||||
let dstW = win.w;
|
||||
let dstH = dstW / srcAspect;
|
||||
if (dstH > win.h) {
|
||||
dstH = win.h;
|
||||
dstW = dstH * srcAspect;
|
||||
}
|
||||
cardCanvas.drawImageRect(
|
||||
photoImg,
|
||||
Skia.XYWHRect(pSx, pSy, pVisW, pVisH),
|
||||
Skia.XYWHRect(win.x + (win.w - dstW) / 2, win.y + (win.h - dstH) / 2, dstW, dstH),
|
||||
plainPaint
|
||||
);
|
||||
const seamPaint = own(Skia.Paint());
|
||||
seamPaint.setColor(Skia.Color('#e5e5e5'));
|
||||
seamPaint.setStyle(1);
|
||||
seamPaint.setStrokeWidth(1);
|
||||
cardCanvas.drawRect(Skia.XYWHRect(win.x + 0.5, win.y + 0.5, win.w - 1, win.h - 1), seamPaint);
|
||||
outputSurface = cardSurface;
|
||||
stampCanvas = cardCanvas;
|
||||
stampW = outW;
|
||||
stampH = outH;
|
||||
} else if (frameId === 'wallframe') {
|
||||
flush(surface);
|
||||
const photoImg = own(surface.makeImageSnapshot());
|
||||
cardSource = photoImg;
|
||||
const pw = photoImg.width();
|
||||
const ph = photoImg.height();
|
||||
const wallLand = options?.wallframeLandscape === true;
|
||||
const outW = wallLand ? WALLFRAME_LAND_W : WALLFRAME_W;
|
||||
const outH = wallLand ? WALLFRAME_LAND_H : WALLFRAME_H;
|
||||
const cardSurface = createSurface(outW, outH);
|
||||
if (!cardSurface) return null;
|
||||
own(cardSurface);
|
||||
const cardCanvas = cardSurface.getCanvas();
|
||||
const wfImage = loadWallframe(input.assets);
|
||||
if (!wfImage) {
|
||||
console.error('Failed to decode wallframe.png');
|
||||
return null;
|
||||
}
|
||||
const wl = wallframeLayout(outW, outH, 'fill', wallLand);
|
||||
const win = wl.window;
|
||||
const plainPaint = own(Skia.Paint());
|
||||
const scale = Math.max(win.w / pw, win.h / ph);
|
||||
const srcW = win.w / scale;
|
||||
const srcH = win.h / scale;
|
||||
const planeX = (pw - srcW) / 2;
|
||||
const planeY = (ph - srcH) / 2;
|
||||
const wz = options?.frameWindowZoom ?? null;
|
||||
const wZoom = wz && wz.s > 1.001 ? wz : null;
|
||||
const wVisW = wZoom ? srcW / wZoom.s : srcW;
|
||||
const wVisH = wZoom ? srcH / wZoom.s : srcH;
|
||||
const wSx = wZoom
|
||||
? Math.min(Math.max(planeX + srcW * wZoom.u - wVisW / 2, planeX), planeX + srcW - wVisW)
|
||||
: planeX;
|
||||
const wSy = wZoom
|
||||
? Math.min(Math.max(planeY + srcH * wZoom.v - wVisH / 2, planeY), planeY + srcH - wVisH)
|
||||
: planeY;
|
||||
cardCanvas.drawImageRect(
|
||||
photoImg,
|
||||
Skia.XYWHRect(wSx, wSy, wVisW, wVisH),
|
||||
Skia.XYWHRect(win.x, win.y, win.w, win.h),
|
||||
plainPaint
|
||||
);
|
||||
cardCanvas.save();
|
||||
if (wallLand) {
|
||||
cardCanvas.drawImage(wfImage, 0, 0, plainPaint);
|
||||
} else {
|
||||
cardCanvas.translate(outW, 0);
|
||||
cardCanvas.rotate(90, 0, 0);
|
||||
cardCanvas.drawImage(wfImage, 0, 0, plainPaint);
|
||||
}
|
||||
cardCanvas.restore();
|
||||
outputSurface = cardSurface;
|
||||
stampCanvas = cardCanvas;
|
||||
stampW = outW;
|
||||
stampH = outH;
|
||||
} else {
|
||||
drawFrameOnCanvas(canvas, width, height, frameId);
|
||||
}
|
||||
if (cardSource) {
|
||||
release(owned, cardSource);
|
||||
release(owned, surface);
|
||||
}
|
||||
|
||||
// 8. GPS + custom watermark.
|
||||
const customWm = options?.watermark ?? null;
|
||||
const liteWm = options?.liteMark ?? null;
|
||||
if ((input.useGeotag && input.gpsInfo) || customWm || liteWm) {
|
||||
const loaded = loadFaces(input.assets);
|
||||
if (loaded) {
|
||||
const { typeface, iconTypeface } = loaded;
|
||||
const fontSize = Math.round(stampW * 0.032);
|
||||
const textPaint = own(Skia.Paint());
|
||||
textPaint.setColor(Skia.Color('#f59e0b'));
|
||||
const iconPaint = own(Skia.Paint());
|
||||
iconPaint.setColor(Skia.Color('#ffffff'));
|
||||
|
||||
if (input.useGeotag && input.gpsInfo) {
|
||||
const locationName = input.gpsInfo.locality || 'STREET VIEW';
|
||||
const d = new Date(input.gpsInfo.timestamp);
|
||||
const pad = (n: number) => String(n).padStart(2, '0');
|
||||
const timestampStr = `${pad(d.getDate())}/${pad(d.getMonth() + 1)}/${d.getFullYear()} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||
const gpsPos = options?.gpsWatermark ?? null;
|
||||
const clamp01 = (v: number) => Math.max(0, Math.min(1, v));
|
||||
const gpsSize = Math.round(fontSize * (gpsPos?.size ?? 1));
|
||||
const gpsFont = own(Skia.Font(typeface, gpsSize));
|
||||
const gpsIconFont = own(Skia.Font(iconTypeface, gpsSize));
|
||||
const gpsGap = Math.round(gpsSize * 1.6);
|
||||
const baseX = clamp01(gpsPos?.x ?? 0.05) * stampW;
|
||||
const baseY = clamp01(gpsPos?.y ?? 0.1) * stampH;
|
||||
const lines: { icon: string; text: string }[] = [];
|
||||
if (gpsPos?.showName !== false) lines.push({ icon: '📍', text: locationName });
|
||||
if (gpsPos?.showTime !== false) lines.push({ icon: '📷', text: timestampStr });
|
||||
textPaint.setColor(Skia.Color(gpsPos?.color ?? '#f59e0b'));
|
||||
const wmRot = options?.watermarkRotation ?? 0;
|
||||
stampCanvas.save();
|
||||
if (wmRot) stampCanvas.rotate(wmRot, baseX, baseY);
|
||||
lines.forEach((line, i) => {
|
||||
const rowY = baseY + Math.round(gpsSize * (1.1 + i * 1.2));
|
||||
stampCanvas.drawText(line.icon, baseX, rowY, iconPaint, gpsIconFont);
|
||||
stampCanvas.drawText(line.text, baseX + gpsGap, rowY, textPaint, gpsFont);
|
||||
});
|
||||
stampCanvas.restore();
|
||||
}
|
||||
|
||||
if (customWm || liteWm) {
|
||||
const clamp01 = (v: number) => Math.max(0, Math.min(1, v));
|
||||
const wmRot = options?.watermarkRotation ?? 0;
|
||||
for (const mark of [customWm, liteWm]) {
|
||||
if (!mark) continue;
|
||||
const wmSize = Math.round(fontSize * (mark.size ?? 1));
|
||||
const wmFace = mark.font ? Skia.FontMgr.System().matchFamilyStyle(mark.font) : null;
|
||||
const wmFont = own(Skia.Font(wmFace ?? typeface, wmSize));
|
||||
const wmPaint = own(Skia.Paint());
|
||||
wmPaint.setColor(Skia.Color(mark.color ?? '#f59e0b'));
|
||||
const tx = clamp01(mark.x) * stampW;
|
||||
const ty = clamp01(mark.y) * stampH + Math.round(wmSize * 0.35);
|
||||
stampCanvas.save();
|
||||
if (wmRot) stampCanvas.rotate(wmRot, tx, ty);
|
||||
stampCanvas.drawText(mark.text, tx, ty, wmPaint, wmFont);
|
||||
stampCanvas.restore();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 9. Snapshot, optional output sharpen, JPEG encode + DPI patch.
|
||||
flush(outputSurface);
|
||||
let resultImage = own(outputSurface.makeImageSnapshot());
|
||||
const sharpenAmount =
|
||||
sharpKnob > 0 ? (sharpKnob / 10) * 0.8 : sharpKnob < 0 ? 0 : options?.sharpen ? 0.5 : 0;
|
||||
if (sharpenAmount > 0) {
|
||||
const sharpened = screenSharpenImage(resultImage, sharpenAmount);
|
||||
if (sharpened && sharpened !== resultImage) {
|
||||
own(sharpened);
|
||||
release(owned, resultImage);
|
||||
resultImage = sharpened;
|
||||
}
|
||||
}
|
||||
let bytes = resultImage.encodeToBytes(Skia.ImageFormat.JPEG, 95);
|
||||
if (!bytes || bytes.length === 0) {
|
||||
console.error('Failed to encode image to JPEG');
|
||||
return null;
|
||||
}
|
||||
if (options?.dpi && options.dpi > 0) {
|
||||
bytes = patchJpegDpi(bytes, options.dpi);
|
||||
}
|
||||
return { bytes, width: resultImage.width(), height: resultImage.height() };
|
||||
} catch (error) {
|
||||
console.error('Render failed:', error);
|
||||
return null;
|
||||
} finally {
|
||||
disposeAll(owned);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
// CanvasKit-backed stand-in for `@shopify/react-native-skia`.
|
||||
//
|
||||
// The web build aliases the package name to THIS module (vite `resolve.alias`,
|
||||
// tsconfig `paths`), so every renderer we share with the app — `frameUtils.ts`,
|
||||
// `skiaImage.ts`, the ported `exportEngine.ts` — runs unchanged on CanvasKit.
|
||||
//
|
||||
// W0 probe results that shaped this file (measured in Node, canvaskit-wasm 0.42):
|
||||
// - the default build CANNOT encode JPEG (`encodeToBytes(JPEG)` → null), the
|
||||
// `canvaskit-wasm/full` build can → always import the full build (+0.9MB wasm);
|
||||
// - `Skia.ImageFilter.MakeMatrixConvolution` DOES NOT EXIST in CanvasKit → the
|
||||
// two convolution steps (CLARITY, output sharpen) are re-implemented as an
|
||||
// SkSL runtime effect, see convolve.ts;
|
||||
// - `Surface.MakeOffscreen` does not exist either → raster `MakeSurface` only
|
||||
// (workers have no GPU context, which is fine: the render is CPU anyway);
|
||||
// - `Skia.Font`/`Typeface.MakeFreeTypeFaceFromData`/`Canvas.drawText` all work,
|
||||
// and `assets/NotoEmoji-GPS.ttf` really paints 📍/📷 (1731 lit pixels in the
|
||||
// probe) → GPS watermark needs no Canvas2D fallback.
|
||||
|
||||
import CanvasKitInit from 'canvaskit-wasm/full';
|
||||
|
||||
// Live binding: modules import `{ Skia }` once, `initSkia()` fills it in before
|
||||
// the first render. ESM live bindings make the late assignment visible.
|
||||
export let Skia: any = null;
|
||||
export let CK: any = null;
|
||||
|
||||
export type SkCanvas = any;
|
||||
export type SkPaint = any;
|
||||
export type SkImage = any;
|
||||
export type SkShader = any;
|
||||
export type SkSurface = any;
|
||||
export type SkTypeface = any;
|
||||
export type SkFont = any;
|
||||
|
||||
// CanvasKit's EMBind objects are released with `delete()`; the RN Skia API (and
|
||||
// therefore the shared renderers) calls `dispose()`.
|
||||
const DISPOSABLE = [
|
||||
'Image', 'Surface', 'Paint', 'Shader', 'ImageFilter', 'ColorFilter',
|
||||
'Typeface', 'Font', 'RuntimeEffect', 'Path', 'Canvas', 'BlendMode',
|
||||
];
|
||||
|
||||
function patchPrototypes(canvasKit: any): void {
|
||||
for (const name of DISPOSABLE) {
|
||||
const proto = canvasKit[name]?.prototype;
|
||||
if (proto && typeof proto.delete === 'function' && typeof proto.dispose !== 'function') {
|
||||
proto.dispose = function dispose() { this.delete(); };
|
||||
}
|
||||
}
|
||||
// frameUtils draws strokes with the numeric RN style (0 = fill, 1 = stroke).
|
||||
const setStyle = canvasKit.Paint.prototype.setStyle;
|
||||
canvasKit.Paint.prototype.setStyle = function (style: any) {
|
||||
return setStyle.call(
|
||||
this,
|
||||
typeof style === 'number'
|
||||
? (style === 1 ? canvasKit.PaintStyle.Stroke : canvasKit.PaintStyle.Fill)
|
||||
: style
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
export interface InitSkiaOptions {
|
||||
// Browser: path of canvaskit.wasm (Vite serves it as an asset). Node: omitted.
|
||||
locateFile?: (file: string) => string;
|
||||
}
|
||||
|
||||
function rasterSurface(w: number, h: number) {
|
||||
return CK.MakeSurface(w, h);
|
||||
}
|
||||
|
||||
// ponytail: one OffscreenCanvas per surface. The pipeline holds at most ~3 at
|
||||
// once, far under the browser's ~16 live WebGL contexts; add a size-keyed pool
|
||||
// if batch rendering ever keeps more alive.
|
||||
function gpuSurface(w: number, h: number) {
|
||||
if (typeof OffscreenCanvas === 'undefined') return null;
|
||||
try {
|
||||
const canvas = new OffscreenCanvas(w, h);
|
||||
const surf = CK.MakeWebGLCanvasSurface(canvas);
|
||||
if (!surf) return null;
|
||||
const dispose = surf.dispose.bind(surf);
|
||||
surf.dispose = () => {
|
||||
try {
|
||||
dispose();
|
||||
} finally {
|
||||
canvas.width = 0;
|
||||
canvas.height = 0;
|
||||
}
|
||||
};
|
||||
return surf;
|
||||
} catch {
|
||||
return null; // no GL context available — caller falls back to raster
|
||||
}
|
||||
}
|
||||
|
||||
export async function initSkia(options: InitSkiaOptions = {}): Promise<any> {
|
||||
if (Skia) return Skia;
|
||||
const canvasKit = await (CanvasKitInit as any)(
|
||||
options.locateFile ? { locateFile: options.locateFile } : undefined
|
||||
);
|
||||
CK = canvasKit;
|
||||
patchPrototypes(canvasKit);
|
||||
|
||||
Skia = {
|
||||
// --- geometry / color ---------------------------------------------------
|
||||
XYWHRect: (x: number, y: number, w: number, h: number) => canvasKit.XYWHRect(x, y, w, h),
|
||||
Point: (x: number, y: number) => [x, y],
|
||||
Color: (value: string) =>
|
||||
value === 'transparent'
|
||||
? canvasKit.Color(0, 0, 0, 0)
|
||||
: canvasKit.parseColorString(value),
|
||||
Color4f: (r: number, g: number, b: number, a: number) => canvasKit.Color4f(r, g, b, a),
|
||||
|
||||
// --- factories ----------------------------------------------------------
|
||||
Paint: () => new canvasKit.Paint(),
|
||||
Font: (typeface: any, size: number) => new canvasKit.Font(typeface, size),
|
||||
Data: {
|
||||
// CanvasKit exposes no Data class; callers pass bytes straight through.
|
||||
fromBytes: (bytes: Uint8Array) => bytes,
|
||||
},
|
||||
Image: {
|
||||
MakeImageFromEncoded: (data: Uint8Array) =>
|
||||
canvasKit.MakeImageFromEncoded(
|
||||
data instanceof Uint8Array ? data : new Uint8Array(data as ArrayBuffer)
|
||||
),
|
||||
},
|
||||
Typeface: {
|
||||
MakeFreeTypeFaceFromData: (data: ArrayBuffer) =>
|
||||
canvasKit.Typeface.MakeFreeTypeFaceFromData(data),
|
||||
},
|
||||
ColorFilter: {
|
||||
MakeMatrix: (m: number[]) => canvasKit.ColorFilter.MakeMatrix(m),
|
||||
MakeCompose: (outer: any, inner: any) => canvasKit.ColorFilter.MakeCompose(outer, inner),
|
||||
},
|
||||
ImageFilter: {
|
||||
MakeBlur: (sx: number, sy: number, tile: any, input: any) =>
|
||||
canvasKit.ImageFilter.MakeBlur(sx, sy, tile, input ?? null),
|
||||
MakeCompose: (outer: any, inner: any) =>
|
||||
canvasKit.ImageFilter.MakeCompose(outer ?? null, inner ?? null),
|
||||
MakeColorFilter: (cf: any, input: any) =>
|
||||
canvasKit.ImageFilter.MakeColorFilter(cf, input ?? null),
|
||||
},
|
||||
Shader: {
|
||||
MakeRadialGradient: (
|
||||
center: number[], radius: number, colors: any[], pos: number[], mode: any
|
||||
) => canvasKit.Shader.MakeRadialGradient(center, radius, colors, pos, mode),
|
||||
},
|
||||
RuntimeEffect: {
|
||||
Make: (sksl: string) => canvasKit.RuntimeEffect.Make(sksl),
|
||||
},
|
||||
Surface: {
|
||||
// A WebGL-backed surface is not a nicety here, it is the difference
|
||||
// between usable and unusable: the same 12MP pipeline that finishes in
|
||||
// well under a second on a GL surface took 3.2s for ONE tone pass and
|
||||
// 13.7s for the CLARITY tap in the Node CPU raster benchmark. Raster is
|
||||
// the fallback for a context-less environment (Node spikes, tests).
|
||||
Make: (w: number, h: number) => rasterSurface(w, h),
|
||||
MakeOffscreen: (w: number, h: number) => gpuSurface(w, h) ?? rasterSurface(w, h),
|
||||
},
|
||||
FontMgr: {
|
||||
System: () => fontMgr,
|
||||
},
|
||||
|
||||
// --- enums --------------------------------------------------------------
|
||||
BlendMode: canvasKit.BlendMode,
|
||||
TileMode: canvasKit.TileMode,
|
||||
FilterMode: canvasKit.FilterMode,
|
||||
MipmapMode: canvasKit.MipmapMode,
|
||||
FontStyle: canvasKit.FontStyle,
|
||||
ImageFormat: canvasKit.ImageFormat,
|
||||
ColorType: canvasKit.ColorType,
|
||||
ColorSpace: canvasKit.ColorSpace,
|
||||
AlphaType: canvasKit.AlphaType,
|
||||
PaintStyle: canvasKit.PaintStyle,
|
||||
};
|
||||
|
||||
fontMgr = makeFontMgr(canvasKit);
|
||||
return Skia;
|
||||
}
|
||||
|
||||
// Named-family lookup for the custom watermark's FONT option. Only faces the
|
||||
// bundle ships can be matched on web; anything else falls back to the caller's
|
||||
// default typeface — the honest behaviour when there is no OS font service.
|
||||
let fontMgr: any = null;
|
||||
|
||||
function makeFontMgr(canvasKit: any) {
|
||||
const provider = canvasKit.TypefaceFontProvider.Make();
|
||||
return {
|
||||
registerFace(typeface: any, familyName: string) {
|
||||
provider.registerFont(typeface, familyName);
|
||||
},
|
||||
matchFamilyStyle(family: string) {
|
||||
if (!family) return null;
|
||||
try {
|
||||
return provider.matchFamilyStyle?.(family, canvasKit.FontStyle.Normal) ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"skipLibCheck": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"allowImportingTsExtensions": true,
|
||||
"types": ["node"],
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@shopify/react-native-skia": ["./src/engine/skiaShim.ts"]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
"src/**/*.ts",
|
||||
"src/**/*.tsx",
|
||||
"spike/**/*.ts",
|
||||
"../src/types/index.ts",
|
||||
"../src/utils/colorUtils.ts",
|
||||
"../src/utils/toneShader.ts",
|
||||
"../src/utils/cinemaShader.ts",
|
||||
"../src/utils/frameUtils.ts",
|
||||
"../src/utils/jpegDpi.ts",
|
||||
"../src/utils/skiaImage.ts",
|
||||
"../src/utils/paramDefs.ts",
|
||||
"../src/utils/defaultRecipes.ts"
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user