Add CanvasKit web engine spike + W0 findings (recipes-web)

This commit is contained in:
2026-09-17 16:58:29 +07:00
parent cef3ddb307
commit 8b4c4aa591
16 changed files with 2207 additions and 81 deletions
+20
View File
@@ -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"
]
}
}
+556
View File
@@ -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: {}
+2
View File
@@ -0,0 +1,2 @@
allowBuilds:
esbuild: set this to true or false
+99
View File
@@ -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);
});
+56
View File
@@ -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();
+114
View File
@@ -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>
+14
View File
@@ -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'));
+34
View File
@@ -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);
+10
View File
@@ -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);
+11
View File
@@ -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);
+18
View File
@@ -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
View File
@@ -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);
});
+690
View File
@@ -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);
}
}
+198
View File
@@ -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;
}
},
};
}
+34
View File
@@ -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"
]
}