diff --git a/package-lock.json b/package-lock.json index 75daf51..dc5624c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,6 +22,7 @@ "lodash": "^4.18.1", "mkdirp": "^3.0.1", "moment-timezone": "^0.6.3", + "sharp": "^0.35.4", "v-click-outside": "^3.0.1", "vue": "^2.7.16", "vue-clipboard2": "^0.3.1", @@ -2037,7 +2038,6 @@ "version": "1.11.3", "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz", "integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==", - "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -2740,12 +2740,502 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", - "devOptional": true, "license": "MIT", "engines": { "node": ">=18" } }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.4.tgz", + "integrity": "sha512-Uhfl4V4lhP2nbUVF9+hyH1+luj86f1gUFeo8ALYxFoULoU+G87D43BfeMP8XHsk9boxAnCY/bf2EHwhA7MuGsA==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.3.3" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.4.tgz", + "integrity": "sha512-hWniXY3bG5qKpkKrAwPe4y+VTPmf086YQAnkxWh7uA1YrlRouWGa0M0Mxj3ZjnXFkv7/TD1bTy9lGUK26vRvWw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.3.3" + } + }, + "node_modules/@img/sharp-freebsd-wasm32": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.4.tgz", + "integrity": "sha512-lIsKw/BU+kjB4eZjxrYrZmwOJYi3Ajrv66iAlBmUPyKc3HpnloevB1g3wxGD9P/5BbQ1brBGl65VRRrCvQDEqA==", + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "dependencies": { + "@img/sharp-wasm32": "0.35.4" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.3.tgz", + "integrity": "sha512-suTBPTDGrI9WodccaDdwZItTSaBYASlBk1NSfElSHrUfzu3szG6lvIF58+WiFvnfzuK8ZBFS5zE00PxqxnRiPg==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.3.tgz", + "integrity": "sha512-FVJZ5mITMobmXIz/hPDTw0EintTW5H3WfrxwLqEqjiIihlu+hVRyGrFQ60xl0Lxn7Bt3zdpevPaQi0HEzqz9fw==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.3.tgz", + "integrity": "sha512-3rbU4vqXXc3hY/OiXdl52xZvT0F1yEngWfvqudtPJg/KkyiaQw2DRsFrNzpmLvfavbwOq3qXn36GP8obHRULQA==", + "cpu": [ + "arm" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.3.tgz", + "integrity": "sha512-0DaL0A6Xu6sQSQFwe4iVCrKWU2cCTItnRsYsCdxAMm9NF6twAA9BKnoqy4hqz4+azQ0JHuA26qiUKsf1XJ/v5A==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.3.tgz", + "integrity": "sha512-cdn1OvUBwsXhbC0zSzJnNzf5MZ/mTrobawDvNXBTxe8VtqKAm0sRuEY2Evzovb/w9JMk4TvRxqt1mekSuJz64w==", + "cpu": [ + "ppc64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.3.tgz", + "integrity": "sha512-HjPVx7yKz+0lqdhDlTw1tt90wamBoxhiXpvl1XZpJLiHH4RCJ5yDTqH+VlYPv2fwFs89JFw4c1IexYOcQUi4IQ==", + "cpu": [ + "riscv64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.3.tgz", + "integrity": "sha512-neWLh+3yCNThxnfy3c4BbVBeGgt9aftno+XbT56iK28RgeDs3UOFWviLWlUu0bArYVYJaFDK+RRohbicUNCm8Q==", + "cpu": [ + "s390x" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.3.tgz", + "integrity": "sha512-4vKmvAst9nrowcqquKFAyZJUDolUaIp8uRiN0mWFguJ1IplC9/pitXtlnnlU4aa/eJw3J7i67V+pwUL+wZGdsA==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.3.tgz", + "integrity": "sha512-Y9kQaLMuNoB0bPYOOdcZMaseNrFpPodIWWMrx+CZyydf2xn68j9WYc6sWWRrDwNkzCQjKYfc68L7jKjGlHMibw==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.3.tgz", + "integrity": "sha512-fj8Mv0HHfD1Rr+4I68+3agJynxDWtBFgicTbSOb9Bke6pIwzGcJ+RX/yHjmiEGFMCavY/dxvem7MyNaJF+wDiw==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.4.tgz", + "integrity": "sha512-7OAS8gI0EReKGVN2HssHlM6umJgxF5VI3xN0p9FA91p/YO+ou5hiNghLdZ5BEHztwaaK5+bLKRf8x/o2L2nk9A==", + "cpu": [ + "arm" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.3.3" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.4.tgz", + "integrity": "sha512-De4jpEnAU8Hd5oT0j1G3uL4ZvTuipVMn7YC6vPaJhy6/7EwEae0SVAoBrUMYQbkLGDm85taVWwuPc1a44LTzCQ==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.3.3" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.4.tgz", + "integrity": "sha512-2oYZJeIl4kCcMGk4ouZVjnkCtFrpQFlNEtJ6GbxzhHQchwH0NH/qEb9ykmOl29dqwMq+JhFdZn+1ak2FKhI9fQ==", + "cpu": [ + "ppc64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.3.3" + } + }, + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.4.tgz", + "integrity": "sha512-cPbNChoRURAWdebDIHSenxRpgEdy7JkPydSnUxRm9VvKD7m0/xVaR/8Fzlu81pk5nHEvHH87UZUA7cTtwnbJSA==", + "cpu": [ + "riscv64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.3.3" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.4.tgz", + "integrity": "sha512-RY0JFY8Fd6RonCBtHz+DvadaPkXDSI1AUn6yWL9TipqkZ1vY8w8evqdgyDFnkm4/K1ve1TvZiaePP5oSd4+WVQ==", + "cpu": [ + "s390x" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.3.3" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.4.tgz", + "integrity": "sha512-9qvvEAuk8k89TfWUoX2htWjbAMX8p+NxCppjpcg5k6xMsjhBQPTsoIh36h9Qde4WRuGpJeYnOjdosDn/cnv+OA==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.3.3" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.4.tgz", + "integrity": "sha512-KB5jxpfWQTr0nc3xdHtWChdbifHrBGsd2SM62Eyxrl8afikm+f5qGBU75SJIZBT/S1MC8XyacdlXBMSWq6OURA==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.3.3" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.4.tgz", + "integrity": "sha512-f+eZJZIQNEEd26RPSW+76chwOf1XtA2Y/O+5ocVyLliHkeih3e+jhLVBdNTd2rS3IbNXK8+ug93Vf5ZXtF5Lxg==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.3.3" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.4.tgz", + "integrity": "sha512-zQnl4Kwp7Q6NHsENtU2T/00Zi+w3AQNwz3+UaTyVBy2FpXrzXzGjndpK61onhZjRtRpQXxCTeqw19bVyXOh7jA==", + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.11.3" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-webcontainers-wasm32": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.4.tgz", + "integrity": "sha512-ESfNkywmCfPNyaZjxooddJQiQ+l/nTpGEOGthxiLnIHXC/CmcBixnfwUleX9mCz9ovrUUvKMap/pm8RYbzfwaA==", + "cpu": [ + "wasm32" + ], + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@img/sharp-wasm32": "0.35.4" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.4.tgz", + "integrity": "sha512-iNdlBX9gLVvqe2I3uIJSIKTq6wckP/DYxZtcqxm09x5Gi24DnFBmPAWZmr60ZyYMG0xlzo6goG3670ar+RXvRw==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.4.tgz", + "integrity": "sha512-kqRsbaa5CS6KHlpxnN7WhE6vAAugXyZButpRdvDWetlv6Qv4N9WTcrWzF7tXfB9T7MsoadqdI8hmwLq6UlLvtw==", + "cpu": [ + "ia32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.4.tgz", + "integrity": "sha512-XtmnYhBcrORsJ4XJngyzr/EWP0hRZLAZRFaApdKuviyqF78+ylxh2y06ZmtULAMOnObJ3ucpN0AcwSWnMowTRg==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -7007,7 +7497,6 @@ "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", - "devOptional": true, "license": "Apache-2.0", "engines": { "node": ">=8" @@ -13781,6 +14270,67 @@ "node": ">=8" } }, + "node_modules/sharp": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.4.tgz", + "integrity": "sha512-n++8XWcj+jCOr2IOl7h8LbKnGBDY4aPbmprMONBNFdn0ImXqpGVv5zliDs0V9HbmbCQLpbuo2ej9rAoOQTvMDA==", + "license": "Apache-2.0", + "dependencies": { + "@img/colour": "^1.1.0", + "detect-libc": "^2.1.2", + "semver": "^7.8.5" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.35.4", + "@img/sharp-darwin-x64": "0.35.4", + "@img/sharp-freebsd-wasm32": "0.35.4", + "@img/sharp-libvips-darwin-arm64": "1.3.3", + "@img/sharp-libvips-darwin-x64": "1.3.3", + "@img/sharp-libvips-linux-arm": "1.3.3", + "@img/sharp-libvips-linux-arm64": "1.3.3", + "@img/sharp-libvips-linux-ppc64": "1.3.3", + "@img/sharp-libvips-linux-riscv64": "1.3.3", + "@img/sharp-libvips-linux-s390x": "1.3.3", + "@img/sharp-libvips-linux-x64": "1.3.3", + "@img/sharp-libvips-linuxmusl-arm64": "1.3.3", + "@img/sharp-libvips-linuxmusl-x64": "1.3.3", + "@img/sharp-linux-arm": "0.35.4", + "@img/sharp-linux-arm64": "0.35.4", + "@img/sharp-linux-ppc64": "0.35.4", + "@img/sharp-linux-riscv64": "0.35.4", + "@img/sharp-linux-s390x": "0.35.4", + "@img/sharp-linux-x64": "0.35.4", + "@img/sharp-linuxmusl-arm64": "0.35.4", + "@img/sharp-linuxmusl-x64": "0.35.4", + "@img/sharp-webcontainers-wasm32": "0.35.4", + "@img/sharp-win32-arm64": "0.35.4", + "@img/sharp-win32-ia32": "0.35.4", + "@img/sharp-win32-x64": "0.35.4" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/sharp/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", diff --git a/package.json b/package.json index a352210..6ea5da9 100644 --- a/package.json +++ b/package.json @@ -6,6 +6,10 @@ "#cdn-images": { "workerd": "./src/common/cdn.worker.js", "default": "./src/common/cdn.node.js" + }, + "#image-converter": { + "workerd": "./src/app/screenshots/convert.worker.js", + "default": "./src/app/screenshots/convert.node.js" } }, "engines": { @@ -53,6 +57,7 @@ "lodash": "^4.18.1", "mkdirp": "^3.0.1", "moment-timezone": "^0.6.3", + "sharp": "^0.35.4", "v-click-outside": "^3.0.1", "vue": "^2.7.16", "vue-clipboard2": "^0.3.1", diff --git a/src/app/screenshots/convert.node.js b/src/app/screenshots/convert.node.js new file mode 100644 index 0000000..42030b3 --- /dev/null +++ b/src/app/screenshots/convert.node.js @@ -0,0 +1,6 @@ +import sharp from 'sharp'; + +// Node-only adapter; package imports keep native sharp out of the Worker bundle. +export async function convertToJpeg(png) { + return new Uint8Array(await sharp(png).flatten({ background: '#ffffff' }).jpeg({ quality: 90 }).toBuffer()); +} diff --git a/src/app/screenshots/convert.worker.js b/src/app/screenshots/convert.worker.js new file mode 100644 index 0000000..cbd7943 --- /dev/null +++ b/src/app/screenshots/convert.worker.js @@ -0,0 +1,8 @@ +import { env } from 'cloudflare:workers'; + +export async function convertToJpeg(png, images = env.IMAGES) { + let result = await images.input(new Response(png).body) + .transform({ background: '#ffffff' }) + .output({ format: 'image/jpeg', quality: 90 }); + return new Uint8Array(await result.response().arrayBuffer()); +} diff --git a/src/app/social/posts/SocialPostBase.js b/src/app/social/posts/SocialPostBase.js index a6bc3da..61bd693 100644 --- a/src/app/social/posts/SocialPostBase.js +++ b/src/app/social/posts/SocialPostBase.js @@ -1,3 +1,4 @@ +import { convertToJpeg } from '#image-converter'; import { getTopOfCurrentHour } from '../../../common/time.js'; import { pngSize } from '../../../common/png.js'; @@ -49,14 +50,14 @@ export default class SocialPostBase { let results = []; let text = await this.getText(data); let time = await this.getDataTime(); - let image = await this.getMedia(data, 'image/png'); + let image = await this.getMedia(data); await this.maybeSavePublicImage(data, image.file); let media = { 'image/png': image }; for (let client of clients) { try { let mediaType = client.mediaType ?? 'image/png'; - media[mediaType] ??= await this.getMedia(data, mediaType); + media[mediaType] ??= await this.convertMedia(image, mediaType); await client.send({ status: text, media: [media[mediaType]] }); await this.updateLastPostTime(client, time); results.push({ client: client.key, ok: true }); @@ -87,7 +88,7 @@ export default class SocialPostBase { } let key = this.getTestScreenshotKey(); - let { file } = await this.getMedia(data, 'image/png'); + let { file } = await this.getMedia(data); await this.publicStorage.writeBytes(key, file); this.info(`Saved screenshot as ${key}`); @@ -198,9 +199,8 @@ export default class SocialPostBase { async getImage(data, format) { } // The image as a media attachment: { file, type, width?, height? } - async getMedia(data, mediaType) { - let format = mediaType === 'image/jpeg' ? 'jpeg' : 'png'; - let result = await this.getImage(data, format); + async getMedia(data) { + let result = await this.getImage(data, 'png'); if (result instanceof Uint8Array) { let size = pngSize(result) ?? {}; @@ -210,6 +210,12 @@ export default class SocialPostBase { return { file: result.image, type: result.type, width: result.width, height: result.height }; } + async convertMedia(image, mediaType) { + if (mediaType !== 'image/jpeg') + throw new Error(`Unsupported social image type: ${mediaType}`); + return { ...image, file: await convertToJpeg(image.file), type: mediaType }; + } + // The filename to store the image as (optional) getPublicImageFilename() { } diff --git a/test/social/SocialPostBase.test.js b/test/social/SocialPostBase.test.js index db79bc2..a575db3 100644 --- a/test/social/SocialPostBase.test.js +++ b/test/social/SocialPostBase.test.js @@ -1,5 +1,6 @@ import { test, beforeEach } from 'node:test'; import assert from 'node:assert/strict'; +import sharp from 'sharp'; import SocialPostBase from '../../src/app/social/posts/SocialPostBase.js'; import { storage, fakeClient, json, seed } from './support.js'; @@ -64,18 +65,45 @@ test('a failing client does not record a post time and does not block the other assert.deepEqual(await json(s.privateBucket, 'other-lastPostTimes.json'), { hourly: 3600 }); }); -test('renders once per media type a client needs, with size from the screenshot', async () => { +test('captures one PNG and converts it once for JPEG clients, preserving dimensions', async () => { + const png = await sharp({ create: { width: 2, height: 1, channels: 3, background: '#ff0000' } }).png().toBuffer(); class ScreenshotPost extends HourlyPost { - async getImage(data, format) { this.images++; return { image: new Uint8Array([format === 'jpeg' ? 2 : 1]), type: format === 'jpeg' ? 'image/jpeg' : 'image/png', width: 2432, height: 1368 }; } + async getImage(data, format) { + assert.equal(format, 'png'); + this.images++; + return { image: png, type: 'image/png', width: 2, height: 1 }; + } } - const jpegClient = fakeClient('bluesky'); jpegClient.mediaType = 'image/jpeg'; - const post = new ScreenshotPost(s, [jpegClient, other]); - await post.maybePost(); + bluesky.mediaType = 'image/jpeg'; + const secondJpeg = fakeClient('second'); secondJpeg.mediaType = 'image/jpeg'; + const post = new ScreenshotPost(s, [bluesky, other, secondJpeg]); + assert.equal((await post.maybePost()).ok, true); + assert.equal(post.images, 1); + const jpeg = bluesky.sent[0].media[0]; + assert.equal(jpeg.type, 'image/jpeg'); + assert.equal(jpeg.width, 2); + assert.equal(jpeg.height, 1); + const decoded = await sharp(jpeg.file).metadata(); + assert.equal(decoded.format, 'jpeg'); + assert.equal(decoded.width, 2); + assert.equal(decoded.height, 1); + assert.equal(secondJpeg.sent[0].media[0], jpeg); + assert.deepEqual(other.sent[0].media[0].file, png); + assert.deepEqual(new Uint8Array(await (await s.publicBucket.get('twitter-images/hourly.png')).arrayBuffer()), new Uint8Array(png)); +}); - assert.equal(post.images, 2); // one PNG public copy, one JPEG for Bluesky - assert.deepEqual(jpegClient.sent[0].media, [{ file: new Uint8Array([2]), type: 'image/jpeg', width: 2432, height: 1368 }]); - assert.deepEqual(other.sent[0].media, [{ file: new Uint8Array([1]), type: 'image/png', width: 2432, height: 1368 }]); - assert.deepEqual(new Uint8Array(await (await s.publicBucket.get('twitter-images/hourly.png')).arrayBuffer()), new Uint8Array([1])); +test('conversion failure preserves the PNG and does not checkpoint or send the JPEG post', async () => { + bluesky.mediaType = 'image/jpeg'; + const post = new HourlyPost(s, [bluesky, other]); + post.convertMedia = async () => { throw new Error('Images quota exceeded'); }; + const result = await post.maybePost(); + assert.equal(result.ok, false); + assert.match(result.clients[0].error, /Images quota exceeded/); + assert.equal(bluesky.sent.length, 0); + assert.equal(other.sent.length, 1); + assert.equal(post.images, 1); + assert.ok(await s.publicBucket.get('twitter-images/hourly.png')); + assert.equal(await json(s.privateBucket, 'bluesky-lastPostTimes.json'), null); }); test('with no data there is nothing to post', async () => { diff --git a/workers/updater/README.md b/workers/updater/README.md index ca2f11b..11f40a3 100644 --- a/workers/updater/README.md +++ b/workers/updater/README.md @@ -49,6 +49,18 @@ to preserve existing URLs; that legacy directory name does not enable X posting. With no Bluesky credentials the runner generates public images only. Shadow runs still update some private state, including the remembered Salmon Run shift. +Each post captures one PNG. The public copy stays in R2; when Bluesky needs +JPEG, the Worker's `IMAGES` binding converts those same PNG bytes at quality 90. +The local Node runner uses `sharp` through a conditional package import, keeping +native dependencies out of the Worker bundle. Conversion failures fail that +client's post without advancing its timestamp; no second browser capture is used. +Public-image-only shadow runs do not request conversions. + +The Images Free plan includes 5,000 unique transformations per month; on that +plan, new transformations beyond the quota fail instead of incurring charges. +This uses transformations only, not paid Images storage. See +[Images pricing](https://developers.cloudflare.com/images/pricing/). + Browser Rendering opens `SITE_URL/screenshots.html`. Before rendering or posting, the Worker compares that site's five data JSON files and English localization against its own public bucket. A mismatch fails the run so the diff --git a/workers/updater/images.spec.mjs b/workers/updater/images.spec.mjs new file mode 100644 index 0000000..c354e9c --- /dev/null +++ b/workers/updater/images.spec.mjs @@ -0,0 +1,30 @@ +import { expect, it, vi } from 'vitest'; +import { env } from 'cloudflare:test'; +import { convertToJpeg } from '../../src/app/screenshots/convert.worker.js'; + +it('converts a PNG through the local Images binding without changing its dimensions', async () => { + const png = Uint8Array.from(atob('iVBORw0KGgoAAAANSUhEUgAAAAIAAAABCAIAAAB7QOjdAAAACXBIWXMAAAPoAAAD6AG1e1JrAAAAD0lEQVQImWP4z8Dwn4EBAAj+Af/KOtJRAAAAAElFTkSuQmCC'), c => c.charCodeAt(0)); + const jpeg = await convertToJpeg(png); + expect([...jpeg.slice(0, 3)]).toEqual([255, 216, 255]); + const info = await env.IMAGES.info(new Response(jpeg).body); + expect(info.width).toBe(2); + expect(info.height).toBe(1); +}); + +it('passes the original PNG bytes to Images and returns JPEG bytes', async () => { + const png = new Uint8Array([137, 80, 78, 71]); + const jpeg = new Uint8Array([255, 216, 255]); + let source; + const output = vi.fn(async () => ({ response: () => new Response(jpeg) })); + const transform = vi.fn(() => ({ output })); + const images = { input: stream => { source = stream; return { transform }; } }; + expect(await convertToJpeg(png, images)).toEqual(jpeg); + expect(new Uint8Array(await new Response(source).arrayBuffer())).toEqual(png); + expect(transform).toHaveBeenCalledWith({ background: '#ffffff' }); + expect(output).toHaveBeenCalledWith({ format: 'image/jpeg', quality: 90 }); +}); + +it('propagates Images failures to the posting pipeline', async () => { + const images = { input: () => ({ transform: () => ({ output: async () => { throw new Error('Images quota exceeded'); } }) }) }; + await expect(convertToJpeg(new Uint8Array([1]), images)).rejects.toThrow('Images quota exceeded'); +}); diff --git a/workers/updater/wrangler.jsonc b/workers/updater/wrangler.jsonc index 85ec317..328d720 100644 --- a/workers/updater/wrangler.jsonc +++ b/workers/updater/wrangler.jsonc @@ -30,6 +30,7 @@ // The API token is a secret (CLOUDFLARE_BROWSER_RUN_API_TOKEN). "SITE_URL": "https://splatoon2.ink" }, + "images": { "binding": "IMAGES" }, "r2_buckets": [ { // Shadow mode: writes go to the dev bucket while the container still owns production.