-
-export function hasThumbnail(item: Item): item is Item & ThumbnailProp {
- return thumbnailPropSchema.safeParse(item).success
-}
-
export function fileExtension(name: string): [string, string | undefined] {
const [filename, extension] = name.split(/\.(?=[^\\.]+$)/) as [string, string | undefined]
return [filename, extension?.toLowerCase()]
@@ -142,3 +129,10 @@ export function getPresentationSequence(stepsById: I
export function mapIds(arr1: T[], arr2: U[]) {
return fromPairs(zip(arr1, arr2).map(([e1, e2]) => [e1!.id, e2!.id]))
}
+
+export function determineImageFormat(url: string) {
+ const mimeType = mime.getType(url)
+ if (!mimeType?.startsWith('image/')) return ''
+
+ return mimeType.split('/')[1]
+}
diff --git a/package-lock.json b/package-lock.json
index 891e095..f9c0d5a 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -91,6 +91,9 @@
"name": "tapestry-core",
"version": "1.0.0",
"license": "ISC",
+ "dependencies": {
+ "mime": "^4.1.0"
+ },
"peerDependencies": {
"lodash-es": "^4.17.21",
"zod": "3.25.76"
@@ -132,6 +135,7 @@
"@tiptap/react": "^2.24.2",
"@tiptap/starter-kit": "^2.24.2",
"@tweenjs/tween.js": "^25.0.0",
+ "@vimeo/player": "^2.30.2",
"bowser": "^2.12.1",
"clsx": "^2.1.1",
"color": "^5.0.3",
@@ -145,9 +149,11 @@
"react-pdf": "^10.2.0",
"react-router": "^7.9.6",
"tapestry-core": "^1.0.0",
- "video.js": "^8.23.4"
+ "video.js": "^8.23.4",
+ "youtube-player": "^5.6.0"
},
"devDependencies": {
+ "@types/youtube-player": "^5.5.11",
"eslint-plugin-react-hooks": "^5.2.0",
"stylelint": "^16.25.0",
"stylelint-config-standard": "^39.0.1",
@@ -1844,6 +1850,16 @@
"@noble/ciphers": "^1.0.0"
}
},
+ "node_modules/@emnapi/runtime": {
+ "version": "1.8.1",
+ "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.8.1.tgz",
+ "integrity": "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "tslib": "^2.4.0"
+ }
+ },
"node_modules/@emotion/babel-plugin": {
"version": "11.13.5",
"resolved": "https://registry.npmjs.org/@emotion/babel-plugin/-/babel-plugin-11.13.5.tgz",
@@ -2224,6 +2240,471 @@
"url": "https://github.com/sponsors/nzakas"
}
},
+ "node_modules/@img/colour": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.0.0.tgz",
+ "integrity": "sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@img/sharp-darwin-arm64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz",
+ "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-darwin-arm64": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-darwin-x64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz",
+ "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-darwin-x64": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-libvips-darwin-arm64": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz",
+ "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==",
+ "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.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz",
+ "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==",
+ "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.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz",
+ "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==",
+ "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.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz",
+ "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==",
+ "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.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz",
+ "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==",
+ "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.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz",
+ "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==",
+ "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.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz",
+ "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==",
+ "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.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz",
+ "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==",
+ "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.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz",
+ "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==",
+ "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.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz",
+ "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==",
+ "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.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz",
+ "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==",
+ "cpu": [
+ "arm"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-arm": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-linux-arm64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz",
+ "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-arm64": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-linux-ppc64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz",
+ "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-ppc64": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-linux-riscv64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz",
+ "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==",
+ "cpu": [
+ "riscv64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-riscv64": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-linux-s390x": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz",
+ "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==",
+ "cpu": [
+ "s390x"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-s390x": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-linux-x64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz",
+ "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-x64": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-linuxmusl-arm64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz",
+ "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linuxmusl-arm64": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-linuxmusl-x64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz",
+ "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linuxmusl-x64": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-wasm32": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz",
+ "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==",
+ "cpu": [
+ "wasm32"
+ ],
+ "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
+ "optional": true,
+ "dependencies": {
+ "@emnapi/runtime": "^1.7.0"
+ },
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-win32-arm64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz",
+ "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "Apache-2.0 AND LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-win32-ia32": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz",
+ "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==",
+ "cpu": [
+ "ia32"
+ ],
+ "license": "Apache-2.0 AND LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-win32-x64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz",
+ "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "Apache-2.0 AND LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
"node_modules/@ioredis/commands": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.4.0.tgz",
@@ -5899,6 +6380,13 @@
"@types/node": "*"
}
},
+ "node_modules/@types/youtube-player": {
+ "version": "5.5.11",
+ "resolved": "https://registry.npmjs.org/@types/youtube-player/-/youtube-player-5.5.11.tgz",
+ "integrity": "sha512-pM41CDBqJqBmTeJWnF7NOGz82IQoYOhqzMYXv5vKCXBqGiYSLldxMtpCk6KAEtADTy49S45AriYaCaZyeUX38Q==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.48.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.48.1.tgz",
@@ -6213,6 +6701,16 @@
"is-function": "^1.0.1"
}
},
+ "node_modules/@vimeo/player": {
+ "version": "2.30.2",
+ "resolved": "https://registry.npmjs.org/@vimeo/player/-/player-2.30.2.tgz",
+ "integrity": "sha512-pZx/quHHn0dx7V7qt2n3wcKYFbIvkLvLGsxzBLe4CzTrWTrw+/4KHrlzeB7EiyCrGRTktWmcvEqlqbDbaZ/liw==",
+ "license": "MIT",
+ "dependencies": {
+ "native-promise-only": "0.8.1",
+ "weakmap-polyfill": "2.0.4"
+ }
+ },
"node_modules/@vitejs/plugin-react": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.1.1.tgz",
@@ -7777,7 +8275,6 @@
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
"license": "Apache-2.0",
- "optional": true,
"engines": {
"node": ">=8"
}
@@ -8753,9 +9250,9 @@
"license": "BSD-3-Clause"
},
"node_modules/fast-xml-parser": {
- "version": "5.3.2",
- "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.3.2.tgz",
- "integrity": "sha512-n8v8b6p4Z1sMgqRmqLJm3awW4NX7NkaKPfb3uJIBTSH7Pdvufi3PQ3/lJLQrvxcMYl7JI2jnDO90siPEpD8JBA==",
+ "version": "5.3.7",
+ "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.3.7.tgz",
+ "integrity": "sha512-JzVLro9NQv92pOM/jTCR6mHlJh2FGwtomH8ZQjhFj/R29P2Fnj38OgPJVtcvYw6SuKClhgYuwUZf5b3rd8u2mA==",
"funding": [
{
"type": "github",
@@ -8764,7 +9261,7 @@
],
"license": "MIT",
"dependencies": {
- "strnum": "^2.1.0"
+ "strnum": "^2.1.2"
},
"bin": {
"fxparser": "src/cli/cli.js"
@@ -10174,6 +10671,12 @@
"integrity": "sha512-NT1CJtq3hHIreOianA8aSXn6Cw0JzYOuDQbOrSPe7gqFnCpKP++MQe3ODgO3oh2GJFORkAAdqredOa60z63GbA==",
"license": "MIT"
},
+ "node_modules/load-script": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/load-script/-/load-script-1.0.0.tgz",
+ "integrity": "sha512-kPEjMFtZvwL9TaZo0uZ2ml+Ye9HUMmPwbYRJ324qF9tqMejwykJ5ggTyvzmrbBeapCAbk98BSbTeovHEEP1uCA==",
+ "license": "MIT"
+ },
"node_modules/loader-utils": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-3.3.1.tgz",
@@ -10763,6 +11266,12 @@
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
}
},
+ "node_modules/native-promise-only": {
+ "version": "0.8.1",
+ "resolved": "https://registry.npmjs.org/native-promise-only/-/native-promise-only-0.8.1.tgz",
+ "integrity": "sha512-zkVhZUA3y8mbz652WrL5x0fB0ehrBkulWT3TomAQ9iDtyXZvzKeEA6GPxAItBYeNYl5yngKRX612qHOhvMkDeg==",
+ "license": "MIT"
+ },
"node_modules/natural-compare": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz",
@@ -12816,6 +13325,50 @@
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
"license": "ISC"
},
+ "node_modules/sharp": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz",
+ "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==",
+ "hasInstallScript": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@img/colour": "^1.0.0",
+ "detect-libc": "^2.1.2",
+ "semver": "^7.7.3"
+ },
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-darwin-arm64": "0.34.5",
+ "@img/sharp-darwin-x64": "0.34.5",
+ "@img/sharp-libvips-darwin-arm64": "1.2.4",
+ "@img/sharp-libvips-darwin-x64": "1.2.4",
+ "@img/sharp-libvips-linux-arm": "1.2.4",
+ "@img/sharp-libvips-linux-arm64": "1.2.4",
+ "@img/sharp-libvips-linux-ppc64": "1.2.4",
+ "@img/sharp-libvips-linux-riscv64": "1.2.4",
+ "@img/sharp-libvips-linux-s390x": "1.2.4",
+ "@img/sharp-libvips-linux-x64": "1.2.4",
+ "@img/sharp-libvips-linuxmusl-arm64": "1.2.4",
+ "@img/sharp-libvips-linuxmusl-x64": "1.2.4",
+ "@img/sharp-linux-arm": "0.34.5",
+ "@img/sharp-linux-arm64": "0.34.5",
+ "@img/sharp-linux-ppc64": "0.34.5",
+ "@img/sharp-linux-riscv64": "0.34.5",
+ "@img/sharp-linux-s390x": "0.34.5",
+ "@img/sharp-linux-x64": "0.34.5",
+ "@img/sharp-linuxmusl-arm64": "0.34.5",
+ "@img/sharp-linuxmusl-x64": "0.34.5",
+ "@img/sharp-wasm32": "0.34.5",
+ "@img/sharp-win32-arm64": "0.34.5",
+ "@img/sharp-win32-ia32": "0.34.5",
+ "@img/sharp-win32-x64": "0.34.5"
+ }
+ },
"node_modules/shebang-command": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
@@ -12935,6 +13488,12 @@
"node": ">=10"
}
},
+ "node_modules/sister": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/sister/-/sister-3.0.2.tgz",
+ "integrity": "sha512-p19rtTs+NksBRKW9qn0UhZ8/TUI9BPw9lmtHny+Y3TinWlOa9jWh9xB0AtPSdmOy49NJJJSSe0Ey4C7h0TrcYA==",
+ "license": "BSD-3-Clause"
+ },
"node_modules/slash": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz",
@@ -13324,9 +13883,9 @@
"license": "MIT"
},
"node_modules/strnum": {
- "version": "2.1.1",
- "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.1.1.tgz",
- "integrity": "sha512-7ZvoFTiCnGxBtDqJ//Cu6fWtZtc7Y3x+QOirG15wztbdngGSkht27o2pyGWrVy0b4WAy3jbKmnoK6g5VlVNUUw==",
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.1.2.tgz",
+ "integrity": "sha512-l63NF9y/cLROq/yqKXSLtcMeeyOfnSQlfMSlzFt/K73oIaD8DGaQWd7Z34X9GPiKqP5rbSh84Hl4bOlLcjiSrQ==",
"funding": [
{
"type": "github",
@@ -14915,6 +15474,15 @@
"integrity": "sha512-/5PSaKkIC7PblJKCmkSfuFQK/k/VgAflaVIVtu+owj/NqyKn0p4ni6eFwZD6lfm66PdiPZrc5y9OZ/GDzkAS0Q==",
"license": "BSD-3-Clause"
},
+ "node_modules/weakmap-polyfill": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/weakmap-polyfill/-/weakmap-polyfill-2.0.4.tgz",
+ "integrity": "sha512-ZzxBf288iALJseijWelmECm/1x7ZwQn3sMYIkDr2VvZp7r6SEKuT8D0O9Wiq6L9Nl5mazrOMcmiZE/2NCenaxw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=8.10.0"
+ }
+ },
"node_modules/web-streams-polyfill": {
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz",
@@ -15153,6 +15721,17 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/youtube-player": {
+ "version": "5.6.0",
+ "resolved": "https://registry.npmjs.org/youtube-player/-/youtube-player-5.6.0.tgz",
+ "integrity": "sha512-x95fBbxV7eZ1ZsFtMLMcSGX0Jb/GPPj69RsooyEDVa9bzvvNZ4d5VjnBVBYoY85008VefkLvtaV+b+l38R/LMQ==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "debug": "^4.3.4",
+ "load-script": "^1.0.0",
+ "sister": "^3.0.0"
+ }
+ },
"node_modules/zod": {
"version": "3.25.76",
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
@@ -15207,6 +15786,7 @@
"morgan": "^1.10.1",
"pg-listen": "^1.7.0",
"puppeteer": "^24.3.0",
+ "sharp": "^0.34.5",
"socket.io": "^4.8.1",
"tapestry-core": "^1.0.0",
"tapestry-shared": "^1.0.0",
diff --git a/server/package.json b/server/package.json
index f0609c3..8b69c28 100644
--- a/server/package.json
+++ b/server/package.json
@@ -60,6 +60,7 @@
"morgan": "^1.10.1",
"pg-listen": "^1.7.0",
"puppeteer": "^24.3.0",
+ "sharp": "^0.34.5",
"socket.io": "^4.8.1",
"tapestry-core": "^1.0.0",
"tapestry-shared": "^1.0.0",
diff --git a/server/prisma/migrations/20260212112053_add_image_asset_renditions/migration.sql b/server/prisma/migrations/20260212112053_add_image_asset_renditions/migration.sql
new file mode 100644
index 0000000..b64fec5
--- /dev/null
+++ b/server/prisma/migrations/20260212112053_add_image_asset_renditions/migration.sql
@@ -0,0 +1,146 @@
+-- CreateTable
+CREATE TABLE "ImageAsset" (
+ "id" TEXT NOT NULL,
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "updatedAt" TIMESTAMP(3) NOT NULL,
+ CONSTRAINT "ImageAsset_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateTable
+CREATE TABLE "ImageAssetRendition" (
+ "id" TEXT NOT NULL,
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "updatedAt" TIMESTAMP(3) NOT NULL,
+ "assetId" TEXT NOT NULL,
+ "isPrimary" BOOLEAN NOT NULL,
+ "isAutoGenerated" BOOLEAN NOT NULL,
+ "source" TEXT NOT NULL,
+ "format" TEXT NOT NULL,
+ "width" INTEGER NOT NULL,
+ "height" INTEGER NOT NULL,
+ CONSTRAINT "ImageAssetRendition_pkey" PRIMARY KEY ("id")
+);
+
+-- AddForeignKey
+ALTER TABLE
+ "ImageAssetRendition"
+ADD
+ CONSTRAINT "ImageAssetRendition_assetId_fkey" FOREIGN KEY ("assetId") REFERENCES "ImageAsset"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+ALTER TABLE
+ "Item"
+ADD
+ COLUMN "thumbnailId" TEXT;
+
+-- AddForeignKey
+ALTER TABLE
+ "Item"
+ADD
+ CONSTRAINT "Item_thumbnailId_fkey" FOREIGN KEY ("thumbnailId") REFERENCES "ImageAsset"("id") ON DELETE
+SET
+ NULL ON UPDATE CASCADE;
+
+WITH "items" AS (
+ SELECT
+ "i"."id" AS "itemId",
+ COALESCE("i"."customThumbnail", "i"."thumbnail") AS "thumbnail",
+ ("i"."customThumbnail" IS NULL) AS "isAutoGenerated",
+ "i"."thumbnailWidth",
+ "i"."thumbnailHeight"
+ FROM
+ "Item" "i"
+ WHERE
+ "i"."customThumbnail" IS NOT NULL
+ OR "i"."thumbnail" IS NOT NULL
+),
+"assets" AS (
+ INSERT INTO
+ "ImageAsset" ("id", "updatedAt")
+ SELECT
+ gen_random_uuid(),
+ now()
+ FROM
+ "items" RETURNING "id"
+),
+"paired" AS (
+ SELECT
+ "i"."itemId",
+ "i"."thumbnail",
+ "i"."isAutoGenerated",
+ "i"."thumbnailWidth",
+ "i"."thumbnailHeight",
+ "a"."id" AS "assetId"
+ FROM
+ (
+ SELECT
+ "itemId",
+ "thumbnail",
+ "isAutoGenerated",
+ "thumbnailWidth",
+ "thumbnailHeight",
+ row_number() OVER (
+ ORDER BY
+ "itemId"
+ ) AS "rn"
+ FROM
+ "items"
+ ) "i"
+ JOIN (
+ SELECT
+ "id",
+ row_number() OVER (
+ ORDER BY
+ "id"
+ ) AS "rn"
+ FROM
+ "assets"
+ ) "a" USING ("rn")
+),
+"renditions" AS (
+ INSERT INTO
+ "ImageAssetRendition" (
+ "id",
+ "updatedAt",
+ "assetId",
+ "source",
+ "isPrimary",
+ "isAutoGenerated",
+ "format",
+ "width",
+ "height"
+ )
+ SELECT
+ gen_random_uuid(),
+ now(),
+ "p"."assetId",
+ "p"."thumbnail",
+ TRUE,
+ "p"."isAutoGenerated",
+ LOWER(
+ SUBSTRING(
+ "p"."thumbnail"
+ FROM
+ '\.(\w+)(\?|$)'
+ )
+ ),
+ COALESCE("p"."thumbnailWidth", 0),
+ COALESCE("p"."thumbnailHeight", 0)
+ FROM
+ "paired" "p" RETURNING "id",
+ "assetId"
+)
+UPDATE
+ "Item" "i"
+SET
+ "thumbnailId" = "p"."assetId"
+FROM
+ "paired" "p"
+WHERE
+ "i"."id" = "p"."itemId";
+
+-- AlterTable
+ALTER TABLE
+ "Item" DROP COLUMN "customThumbnail",
+ DROP COLUMN "thumbnail",
+ DROP COLUMN "thumbnailHeight",
+ DROP COLUMN "thumbnailWidth";
diff --git a/server/prisma/schema.prisma b/server/prisma/schema.prisma
index 0d22e1e..a7c2e6f 100644
--- a/server/prisma/schema.prisma
+++ b/server/prisma/schema.prisma
@@ -149,6 +149,28 @@ enum ActionType {
externalLink
}
+model ImageAsset {
+ id String @id @default(uuid())
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
+ renditions ImageAssetRendition[]
+ thumbnailForItems Item[] @relation("thumbnail")
+}
+
+model ImageAssetRendition {
+ id String @id @default(uuid())
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
+ asset ImageAsset @relation(fields: [assetId], references: [id], onDelete: Cascade)
+ assetId String
+ isPrimary Boolean
+ isAutoGenerated Boolean
+ source String
+ format String
+ width Int
+ height Int
+}
+
model Item {
id String @id @default(uuid())
createdAt DateTime @default(now())
@@ -166,10 +188,8 @@ model Item {
text String?
backgroundColor String?
source String?
- thumbnail String?
- customThumbnail String?
- thumbnailWidth Int?
- thumbnailHeight Int?
+ thumbnailId String?
+ thumbnail ImageAsset? @relation("thumbnail", fields: [thumbnailId], references: [id], onDelete: SetNull)
startTime Int?
stopTime Int?
groupId String?
diff --git a/server/prisma/scripts/generate-thumbnails.ts b/server/prisma/scripts/generate-thumbnails.ts
index 7aa719f..ddb35f1 100644
--- a/server/prisma/scripts/generate-thumbnails.ts
+++ b/server/prisma/scripts/generate-thumbnails.ts
@@ -1,14 +1,20 @@
import { ItemType } from '@prisma/client'
import { prisma } from '../../src/db.js'
-import { scheduleItemThumbnailGeneration } from '../../src/resources/items.js'
+import { scheduleItemThumbnailProcessing } from '../../src/resources/items.js'
interface Options {
tapestryId?: string
ids?: string[]
types?: ItemType[]
+ forceRegenerate?: boolean
}
-export async function generateThumbnails({ tapestryId, ids, types }: Options = {}) {
+export async function generateThumbnails({
+ tapestryId,
+ ids,
+ types,
+ forceRegenerate,
+}: Options = {}) {
const items = await prisma.item.findMany({
where: {
tapestryId,
@@ -17,6 +23,6 @@ export async function generateThumbnails({ tapestryId, ids, types }: Options = {
},
})
for (const item of items) {
- await scheduleItemThumbnailGeneration(item.id, true)
+ await scheduleItemThumbnailProcessing(item.id, { skipDelay: true, forceRegenerate })
}
}
diff --git a/server/src/config.ts b/server/src/config.ts
index 1774c8a..c967e60 100644
--- a/server/src/config.ts
+++ b/server/src/config.ts
@@ -62,7 +62,7 @@ export const config = deepFreeze(
// 5 minute timeout may look too long but some larger tapestries with a lot of iframes load slowly
// so we better wait for a while in order to take a nicer screenshot.
TAPESTRY_THUMBNAIL_GENERATION_TIMEOUT: NullishInt(300_000),
- ITEM_THUMBNAIL_GENERATION_DELAY: NullishInt(120_000),
+ ITEM_THUMBNAIL_PROCESSING_DELAY: NullishInt(5_000),
// Queue monitoring
JOBS_ADMIN_NAME: z.string().nullish(),
@@ -128,7 +128,7 @@ export const config = deepFreeze(
s3CleanupPattern: input.S3_CLEAN_UP_CRON_PATTERN,
tapestryThumbnailGenerationDelay: input.TAPESTRY_THUMBNAIL_GENERATION_DELAY,
tapestryThumbnailGenerationTimeout: input.TAPESTRY_THUMBNAIL_GENERATION_TIMEOUT,
- itemThumbnailGenerationDelay: input.ITEM_THUMBNAIL_GENERATION_DELAY,
+ itemThumbnailProcessingDelay: input.ITEM_THUMBNAIL_PROCESSING_DELAY,
queueAdminName: input.JOBS_ADMIN_NAME,
queueAdminPassword: input.JOBS_ADMIN_PASSWORD,
},
diff --git a/server/src/resources/items.ts b/server/src/resources/items.ts
index 37c5303..167aba3 100644
--- a/server/src/resources/items.ts
+++ b/server/src/resources/items.ts
@@ -22,42 +22,59 @@ import {
MediaItemUpdateDto,
} from 'tapestry-shared/src/data-transfer/resources/dtos/item.js'
import { ReadParamsDto } from 'tapestry-shared/src/data-transfer/resources/dtos/common.js'
-import { ensureArray, OneOrMore } from 'tapestry-core/src/utils.js'
+import { determineImageFormat, ensureArray, OneOrMore } from 'tapestry-core/src/utils.js'
import { socketIdFromRequest, socketServer } from '../socket/index.js'
-import { groupBy } from 'lodash-es'
+import { compact, get, groupBy, isEmpty, omit } from 'lodash-es'
import { findWebSourceParser } from 'tapestry-core/src/web-sources/index.js'
import { MEDIA_ITEM_TYPES } from 'tapestry-core/src/data-format/schemas/item.js'
+import { extractInternallyHostedS3Key } from '../services/s3-service.js'
+import { Path, WithOptional } from 'tapestry-core/src/type-utils.js'
-export function shouldRegenerateItemThumbnail(item: Item, patch?: Partial- ) {
- if (item.type === 'pdf') return !item.thumbnail || patch?.defaultPage !== undefined
- if (item.type === 'video') return !item.thumbnail || patch?.startTime !== undefined
+type ItemWithRequiredAssets = Prisma.ItemGetPayload<{
+ include: { thumbnail: { include: { renditions: true } } }
+}>
- // Generate thumbnails only for webpage items.
- if (item.type !== 'webpage') return false
+type ItemWithAssets = WithOptional
- // If an item has just been created, generate a thumbnail only if it wasn't created with one.
- if (!patch) return !item.thumbnail
+function isChanging(patch: ItemUpdateDto | undefined, ...keys: Path[]): boolean {
+ return keys.some((key) => get(patch, key) !== undefined)
+}
- // The item is being updated. If the update contains a thumbnail, skip thumbnail creation.
- if (patch.thumbnail) return false
+function parseItemIncludes(include: ReadParamsDto['include']) {
+ return parseIncludes('Item', ['thumbnail.renditions', ...(include ?? [])])
+}
- // The item is being updated. Generate a thumbnail only if it has been modified in a way
- // which would change the appearance of its thumbnail.
- return (
- (patch.webpageType !== undefined && patch.webpageType !== item.webpageType) ||
- (patch.source !== undefined && patch.source !== item.source) ||
- (patch.width !== undefined && patch.width !== item.thumbnailWidth) ||
- (patch.height !== undefined && patch.height !== item.thumbnailHeight)
- )
+function shouldProcessItemThumbnail(item: ItemWithAssets, patch?: ItemUpdateDto) {
+ // If an item has just been created or its thumbnail is updated by the user, we should process it -
+ // either create a new thumbnail, or transcode the one the user has uploaded.
+ if (!patch || patch.thumbnail) return true
+
+ // If the item doesn't have a thumbnail or its size or source is being changed, schedule thumbnail creation.
+ if (isEmpty(item.thumbnail) || isChanging(patch, 'size.width', 'size.height', 'source'))
+ return true
+
+ // Otherwise, re-generate the thumbnail only if the current patch is changing properties that
+ // affect the visual appearance of the item.
+ if (item.type === 'pdf') return isChanging(patch, 'defaultPage')
+ if (item.type === 'video') return isChanging(patch, 'startTime')
+ if (item.type === 'webpage') return isChanging(patch, 'webpageType')
+ if (item.type === 'text' || item.type === 'actionButton') {
+ return isChanging(patch, 'backgroundColor', 'text')
+ }
+
+ return false
}
-export function scheduleItemThumbnailGeneration(itemId: string, skipDelay = false) {
+export function scheduleItemThumbnailProcessing(
+ itemId: string,
+ { forceRegenerate = false, skipDelay = false } = {},
+) {
return queue.add(
- 'generate-item-thumbnail',
- { itemId },
+ 'process-item-thumbnail',
+ { itemId, forceRegenerate },
{
jobId: itemId,
- delay: skipDelay ? 0 : config.worker.itemThumbnailGenerationDelay,
+ delay: skipDelay ? 0 : config.worker.itemThumbnailProcessingDelay,
removeOnComplete: true,
removeOnFail: true,
},
@@ -103,6 +120,39 @@ async function resolveWebSource(item: ItemCreateDto | ItemUpdateDto) {
item.webpageType = webSourceParser.webpageType
}
+async function processThumbnailUpdate(
+ item: Item | null,
+ thumbnailUpdate: ItemUpdateDto['thumbnail'] | null | undefined,
+ tx?: Prisma.TransactionClient,
+) {
+ if (thumbnailUpdate === undefined) return
+
+ return await ensureTransaction(tx, async (tx) => {
+ if (item?.thumbnailId) {
+ await tx.imageAsset.delete({ where: { id: item.thumbnailId } })
+ }
+
+ if (!thumbnailUpdate) return
+
+ const { source: url, size } = thumbnailUpdate
+ const source = extractInternallyHostedS3Key(url) ?? url
+ await tx.imageAsset.create({
+ data: {
+ renditions: {
+ create: {
+ source,
+ format: determineImageFormat(source),
+ isPrimary: true,
+ isAutoGenerated: false,
+ width: size.width,
+ height: size.height,
+ },
+ },
+ },
+ })
+ })
+}
+
function isMediaItemUpdate(update: ItemUpdateDto): update is MediaItemUpdateDto {
return MEDIA_ITEM_TYPES.includes(update.type)
}
@@ -132,14 +182,14 @@ export async function createItems(
await Promise.all(items.map(resolveWebSource))
const itemData = items.map((item) => itemDtoToDb(item, ['createdAt', 'updatedAt']))
- const dbItems = await (tx ?? prisma).item.createManyAndReturn({
+ const dbItems = (await (tx ?? prisma).item.createManyAndReturn({
data: itemData,
- include: parseIncludes('Item', query?.include),
- })
+ include: parseItemIncludes(query?.include),
+ })) as ItemWithAssets[]
dbItems
- .filter((dbItem) => shouldRegenerateItemThumbnail(dbItem))
- .forEach((dbItem) => scheduleItemThumbnailGeneration(dbItem.id, true))
+ .filter((dbItem) => shouldProcessItemThumbnail(dbItem))
+ .forEach((dbItem) => scheduleItemThumbnailProcessing(dbItem.id))
const dtos = await serialize('Item', dbItems)
@@ -162,7 +212,7 @@ export async function updateItems(
const items = await ensureTransaction(tx, (tx) =>
Promise.all(
Object.entries(updates).map(async ([id, update]) => {
- let dbItem = await tx.item.findUniqueOrThrow({ where: { id } })
+ const dbItem = await tx.item.findUniqueOrThrow({ where: { id } })
if (dbItem.type !== update.type) {
throw new BadRequestError('Item type cannot be changed')
@@ -172,24 +222,25 @@ export async function updateItems(
await resolveWebSource(update)
}
- const patch = itemDtoToDb(update, ['id', 'createdAt', 'updatedAt'])
- dbItem = await tx.item.update({
+ await processThumbnailUpdate(dbItem, update.thumbnail, tx)
+ const patch = itemDtoToDb(omit(update, 'thumbnail'), ['id', 'createdAt', 'updatedAt'])
+ const updatedDbItem = (await tx.item.update({
where: { id },
data: patch,
- include: parseIncludes('Item', query?.include),
- })
+ include: parseItemIncludes(query?.include),
+ })) as ItemWithAssets
- // Even though the item thumbnail generation job schedules a follow up job
+ // Even though the item thumbnail processing job schedules a follow up job
// for generating a tapestry thumbnail we want to schedule a tapestry thumbnail
// in the cases where for example we move an item (then the item's thumbnail is not regenerated)
- if (shouldRegenerateItemThumbnail(dbItem, patch)) {
- void scheduleItemThumbnailGeneration(id)
+ if (shouldProcessItemThumbnail(updatedDbItem, update)) {
+ void scheduleItemThumbnailProcessing(id, { forceRegenerate: true })
}
- void scheduleTapestryThumbnailGeneration(dbItem.tapestryId)
+ void scheduleTapestryThumbnailGeneration(updatedDbItem.tapestryId)
- updatedTapestries.add(dbItem.tapestryId)
+ updatedTapestries.add(updatedDbItem.tapestryId)
- return serialize('Item', dbItem)
+ return serialize('Item', updatedDbItem)
}),
),
)
@@ -222,6 +273,11 @@ export async function destroyItems(
const payload = await tx.item.deleteMany({ where: { id: { in: ids } } })
+ const imageAssetIds = compact(items.map((item) => item.tapestryId))
+ if (imageAssetIds.length > 0) {
+ await tx.imageAsset.deleteMany({ where: { id: { in: imageAssetIds } } })
+ }
+
for (const [tapestryId, tapestryItems] of Object.entries(itemsByTapestry)) {
void scheduleTapestryThumbnailGeneration(tapestryId)
socketServer.notifyTapestryElementsRemoved(
@@ -253,7 +309,7 @@ export const items: RESTResourceImpl = {
read: async ({ pathParams: { id }, query }) => {
const dbItem = await prisma.item.findUniqueOrThrow({
where: { id },
- include: parseIncludes('Item', query.include),
+ include: parseItemIncludes(query.include),
})
return serialize('Item', dbItem)
@@ -272,7 +328,7 @@ export const items: RESTResourceImpl = {
const total = await prisma.item.count({ where })
const items = await prisma.item.findMany({
where,
- include: parseIncludes('Item', query.include),
+ include: parseItemIncludes(query.include),
orderBy: filter.orderBy,
skip: filter.skip,
take: filter.limit,
diff --git a/server/src/resources/tapestries.ts b/server/src/resources/tapestries.ts
index edde6bf..7ff1664 100644
--- a/server/src/resources/tapestries.ts
+++ b/server/src/resources/tapestries.ts
@@ -355,6 +355,9 @@ export const tapestries: RESTResourceImpl[number]
+import { Item } from 'tapestry-core/src/data-format/schemas/item.js'
+import { generateItemThumbnailRenditionName } from 'tapestry-shared/src/utils.js'
class ImportError extends BadRequestError {
constructor(
@@ -37,13 +37,7 @@ class ImportError extends BadRequestError {
}
}
-function getThumbnail(i: ExportItem) {
- if (i.type === 'video' || i.type === 'webpage') {
- return i.thumbnail
- }
-}
-
-function isMediaItem(i: ExportItem) {
+function isMediaItem(i: Item) {
return (
i.type === 'audio' ||
i.type === 'book' ||
@@ -54,8 +48,8 @@ function isMediaItem(i: ExportItem) {
)
}
-function isPlayable(i: ExportItem) {
- return i.type === 'webpage' || i.type === 'video' || i.type === 'audio'
+function hasStartStopTime(i: Item) {
+ return i.type === 'video' || i.type === 'audio'
}
function* mediaItems(tapestry: CurrentExport) {
@@ -178,25 +172,46 @@ export class TapestryImportService {
return generateItemKey(tapestryId, filename)
})
}
+ }
- const itemThumbnail = getThumbnail(item)
- if (itemThumbnail) {
- itemThumbnail.source = await this.uploadSource(itemThumbnail.source, () =>
- tapestryKey(tapestryId, `item-${item.id}-thumbnail.jpeg`),
+ const itemThumbnailsMap: IdMap = {}
+ const itemThumbnailRenditions: Prisma.ImageAssetRenditionCreateManyInput[] = []
+ for (const item of tapestry.items ?? []) {
+ if (item.thumbnail?.renditions.length) {
+ const thumbnail = { id: crypto.randomUUID() }
+ const renditions = await Promise.all(
+ item.thumbnail.renditions.map(
+ async (r): Promise => ({
+ assetId: thumbnail.id,
+ source: await this.uploadSource(r.source, (_, ext) =>
+ tapestryKey(
+ tapestryId,
+ `${generateItemThumbnailRenditionName(item.id, r)}.${ext}`,
+ ),
+ ),
+ format: r.format,
+ width: r.size.width,
+ height: r.size.height,
+ isPrimary: r.isPrimary,
+ isAutoGenerated: r.isAutoGenerated,
+ }),
+ ),
)
- }
- if (item.customThumbnail) {
- item.customThumbnail = await this.uploadSource(item.customThumbnail, (_, ext) =>
- tapestryKey(tapestryId, `item-${item.id}-custom-thumbnail.${ext}`),
- )
+ itemThumbnailsMap[item.id] = thumbnail
+ itemThumbnailRenditions.push(...renditions)
}
}
+ const itemThumbnails = idMapToArray(itemThumbnailsMap)
+ if (itemThumbnails.length > 0) {
+ await tx.imageAsset.createMany({ data: itemThumbnails })
+ await tx.imageAssetRendition.createMany({ data: itemThumbnailRenditions })
+ }
+
const items = await tx.item.createManyAndReturn({
data: await Promise.all(
tapestry.items?.map>(async (i) => {
- const thumbnail = getThumbnail(i)
const isMedia = isMediaItem(i)
const source = isMedia ? i.source : undefined
@@ -209,9 +224,8 @@ export class TapestryImportService {
notes: i.notes,
dropShadow: !!i.dropShadow,
groupId: groupIdMap[i.groupId ?? ''],
- customThumbnail: i.customThumbnail,
-
- backgroundColor: isMedia ? undefined : i.backgroundColor,
+ thumbnailId: itemThumbnailsMap[i.id]?.id,
+ backgroundColor: isMediaItem(i) ? undefined : i.backgroundColor,
text: isMedia ? undefined : i.text,
...(i.type === 'actionButton'
@@ -225,11 +239,7 @@ export class TapestryImportService {
i.type === 'webpage'
? (i.webpageType ?? (await determineWebpageType(i.source)))
: null,
- ...(isPlayable(i) ? { startTime: i.startTime, stopTime: i.stopTime } : {}),
-
- thumbnail: thumbnail?.source,
- thumbnailHeight: thumbnail?.size.height,
- thumbnailWidth: thumbnail?.size.width,
+ ...(hasStartStopTime(i) ? { startTime: i.startTime, stopTime: i.stopTime } : {}),
defaultPage: i.type === 'pdf' ? i.defaultPage : null,
}
}) ?? [],
diff --git a/server/src/tasks/ffmpeg.ts b/server/src/tasks/ffmpeg.ts
deleted file mode 100644
index 49f9e45..0000000
--- a/server/src/tasks/ffmpeg.ts
+++ /dev/null
@@ -1,20 +0,0 @@
-import { exec } from './utils'
-
-export async function getVideoFPS(source: string): Promise {
- const [num, denom] = (
- await exec(
- `ffprobe -v 0 -of csv=p=0 -select_streams v:0 -show_entries stream=r_frame_rate "${source}"`,
- )
- ).split('/')
-
- return Math.round(Number.parseInt(num) / Number.parseInt(denom))
-}
-
-export async function extractVideoFrame(source: string, frame = 0): Promise {
- return Buffer.from(
- await exec(
- `ffmpeg -i "${source}" -vf "select=eq(n\\,${frame})" -vframes 1 -f image2 pipe:1 2>/dev/null | base64`,
- ),
- 'base64',
- )
-}
diff --git a/server/src/tasks/generate-item-thumbnail.ts b/server/src/tasks/generate-item-thumbnail.ts
deleted file mode 100644
index 9b5252c..0000000
--- a/server/src/tasks/generate-item-thumbnail.ts
+++ /dev/null
@@ -1,102 +0,0 @@
-import { JobTypeMap } from './index.js'
-import { s3Service, tapestryKey } from '../services/s3-service.js'
-import { prisma } from '../db.js'
-import { takeScreenshot } from './puppeteer.js'
-import axios from 'axios'
-import { OEmbed } from 'tapestry-core/src/oembed.js'
-import { Item, ItemType } from '@prisma/client'
-import { scheduleTapestryThumbnailGeneration } from '../resources/tapestries.js'
-import { parseDBItemSource } from '../transformers/item.js'
-import { WEB_SOURCE_PARSERS } from 'tapestry-core/src/web-sources/index.js'
-import { extractVideoFrame, getVideoFPS } from './ffmpeg.js'
-import { screenshotPage } from './pdf.js'
-
-async function getPDFThumbnail(item: Item) {
- const { source } = await parseDBItemSource(item.source!)
- if (source.startsWith('blob:')) {
- return
- }
-
- return screenshotPage(source, item.defaultPage ?? 1)
-}
-
-async function getYoutubeThumbnail(embedUrl: string) {
- const { data: oembed } = await axios.get(
- `https://www.youtube.com/oembed?url=${WEB_SOURCE_PARSERS.youtube.getWatchUrl(embedUrl)}`,
- )
- const { type, thumbnail_url } = oembed
- if (type !== 'video' || !thumbnail_url) {
- return undefined
- }
- const { data: blob } = await axios.get(thumbnail_url, {
- responseType: 'arraybuffer',
- })
- return blob
-}
-
-async function getVideoThumbnail(item: Item): Promise {
- const { source } = await parseDBItemSource(item.source!)
- // Upon initial upload the source is the local blob, so we skip it.
- // After uploading the video to S3 an item update will come with the object key
- // based off of which we will extract the video thumbnail
- if (source.startsWith('blob:')) {
- return
- }
- const startFrame = item.startTime ? (await getVideoFPS(source)) * item.startTime : undefined
- return extractVideoFrame(source, startFrame)
-}
-
-async function getThumbnail(item: Item) {
- if (item.type === 'pdf') {
- return getPDFThumbnail(item)
- }
- if (item.type === 'video') {
- return getVideoThumbnail(item)
- }
-
- const { width, height, source, webpageType } = item
-
- if (webpageType === 'youtube') {
- const buffer = await getYoutubeThumbnail(source!)
- if (buffer) {
- return new Uint8Array(buffer)
- }
- }
-
- return takeScreenshot(source!, { width, height, timeout: 120_000 })
-}
-
-const THUMBNAIL_TYPES: ItemType[] = ['webpage', 'video', 'pdf']
-
-export async function generateItemThumbnail({ itemId }: JobTypeMap['generate-item-thumbnail']) {
- const item = await prisma.item.findUniqueOrThrow({ where: { id: itemId } })
- const { type } = item
-
- if (!THUMBNAIL_TYPES.includes(type)) {
- console.error(`Cannot generate thumbnail for item of type ${type}`)
- return
- }
-
- const thumbnailKey = tapestryKey(item.tapestryId, `item-${itemId}-thumbnail.jpeg`)
- try {
- const thumbnail = await getThumbnail(item)
- if (!thumbnail) {
- return
- }
-
- await s3Service.putObject(thumbnailKey, thumbnail, 'image/jpeg')
-
- await prisma.item.update({
- where: { id: itemId },
- data: {
- thumbnail: thumbnailKey,
- thumbnailWidth: item.width,
- thumbnailHeight: item.height,
- },
- })
-
- await scheduleTapestryThumbnailGeneration(item.tapestryId)
- } catch (error) {
- console.error('Error while generating item thumbnail', error)
- }
-}
diff --git a/server/src/tasks/generate-tapestry-thumbnail.ts b/server/src/tasks/generate-tapestry-thumbnail.ts
index 65d7d49..1048c5c 100644
--- a/server/src/tasks/generate-tapestry-thumbnail.ts
+++ b/server/src/tasks/generate-tapestry-thumbnail.ts
@@ -1,11 +1,8 @@
-import { URL } from 'url'
import { JobTypeMap } from './index.js'
import { config } from '../config.js'
import { s3Service, tapestryKey } from '../services/s3-service.js'
import { prisma } from '../db.js'
-import { createJWT } from '../auth/tokens.js'
-import { takeScreenshot } from './puppeteer.js'
-import { REFRESH_TOKEN_COOKIE_NAME } from '../auth/index.js'
+import { takeTapestryScreenshot } from './thumbnail-generators/tapestry.js'
// 6 times the dimensions of the thumbnail as displayed in the UI
const WIDTH = 6 * 375
@@ -14,29 +11,6 @@ const HEIGHT = Math.floor(WIDTH * (10 / 21))
// Inset to clip toolbars near the edges of the tapestry viewer.
const INSET = 100
-async function screenshot(url: string, userId: string) {
- return takeScreenshot(url, {
- width: WIDTH + 2 * INSET,
- height: HEIGHT + 2 * INSET,
- timeout: config.worker.tapestryThumbnailGenerationTimeout,
- clip: {
- x: INSET,
- y: INSET,
- width: WIDTH,
- height: HEIGHT,
- },
- setupContext: (context) =>
- context.setCookie({
- domain: new URL(config.server.externalUrl).host,
- name: REFRESH_TOKEN_COOKIE_NAME,
- value: createJWT({ userId }, '10m'),
- expires: -1, // Session cookie
- httpOnly: true,
- secure: true,
- }),
- })
-}
-
export async function generateTapestryThumbnail({
tapestryId,
}: JobTypeMap['generate-tapestry-thumbnail']) {
@@ -45,10 +19,17 @@ export async function generateTapestryThumbnail({
const tapestry = await prisma.tapestry.findUniqueOrThrow({
where: { id: tapestryId },
})
- const thumbnail = await screenshot(
- `${config.server.viewerUrl}/t/${tapestryId}`,
- tapestry.ownerId,
- )
+ const thumbnail = await takeTapestryScreenshot(`/t/${tapestryId}`, tapestry.ownerId, {
+ width: WIDTH + 2 * INSET,
+ height: HEIGHT + 2 * INSET,
+ timeout: config.worker.tapestryThumbnailGenerationTimeout,
+ clip: {
+ x: INSET,
+ y: INSET,
+ width: WIDTH,
+ height: HEIGHT,
+ },
+ })
await s3Service.putObject(thumbnailKey, thumbnail, 'image/jpeg')
await prisma.tapestry.update({
diff --git a/server/src/tasks/index.ts b/server/src/tasks/index.ts
index 1b6dc29..0163d43 100644
--- a/server/src/tasks/index.ts
+++ b/server/src/tasks/index.ts
@@ -13,8 +13,9 @@ export interface JobTypeMap {
'generate-tapestry-thumbnail': {
tapestryId: string
}
- 'generate-item-thumbnail': {
+ 'process-item-thumbnail': {
itemId: string
+ forceRegenerate: boolean
}
's3-cleanup': void
'create-tapestry': {
diff --git a/server/src/tasks/pdf.ts b/server/src/tasks/pdf.ts
deleted file mode 100644
index 657f13a..0000000
--- a/server/src/tasks/pdf.ts
+++ /dev/null
@@ -1,24 +0,0 @@
-import { createWriteStream } from 'fs'
-import { exec } from './utils'
-import { tmpdir } from 'os'
-import { resolve } from 'path'
-import { randomUUID } from 'crypto'
-import { Readable } from 'stream'
-import { finished } from 'stream/promises'
-import { unlink } from 'fs/promises'
-
-export async function screenshotPage(src: string, page: number): Promise {
- const { body } = await fetch(src)
- if (!body) {
- return
- }
- const outFile = resolve(tmpdir(), `${randomUUID()}.pdf`)
- await finished(Readable.fromWeb(body).pipe(createWriteStream(outFile)))
-
- const buffer = Buffer.from(
- await exec(`magick "${outFile}[${page - 1}]" -density 96 -quality 75 jpg:- | base64`),
- 'base64',
- )
- await unlink(outFile)
- return buffer
-}
diff --git a/server/src/tasks/process-item-thumbnail.ts b/server/src/tasks/process-item-thumbnail.ts
new file mode 100644
index 0000000..6974fad
--- /dev/null
+++ b/server/src/tasks/process-item-thumbnail.ts
@@ -0,0 +1,146 @@
+import { JobTypeMap } from './index.js'
+import { s3Service, tapestryKey } from '../services/s3-service.js'
+import { prisma } from '../db.js'
+import { ImageAssetRendition, Item } from '@prisma/client'
+import { scheduleTapestryThumbnailGeneration } from '../resources/tapestries.js'
+import { generatePrimaryThumbnail, ThumbnailRenditionOutput } from './thumbnail-generators/index.js'
+import { generateItemThumbnailRenditionName } from 'tapestry-shared/src/utils.js'
+import { parseDBItemSource } from '../transformers/item.js'
+import { downloadImageToArrayBuffer } from './utils.js'
+import { generateThumbnail } from './thumbnail-generators/image.js'
+import sharp from 'sharp'
+
+async function storeThumbnailRendition(
+ item: Item,
+ isPrimary: boolean,
+ generate: (item: Item) => Promise,
+) {
+ try {
+ console.log(`Generating thumbnail rendition for item ${item.id}...`)
+ const output = await generate(item)
+ if (!output) {
+ console.log(`[${item.id}] Thumbnail generation failed.`)
+ return
+ }
+
+ console.log(`[${item.id}] Thumbnail generated. Uploading to S3...`)
+
+ const { data, extension, format, size } = output
+ const renditionName = generateItemThumbnailRenditionName(item.id, {
+ format,
+ size,
+ isPrimary,
+ isAutoGenerated: true,
+ })
+ const renditionKey = tapestryKey(item.tapestryId, `${renditionName}.${extension}`)
+
+ await s3Service.putObject(renditionKey, data, `image/${format}`)
+
+ console.log(`[${item.id}] Thumbnail uploaded. Updating db records...`)
+ const assetId =
+ item.thumbnailId ??
+ (
+ await prisma.imageAsset.create({
+ data: { thumbnailForItems: { connect: { id: item.id } } },
+ })
+ ).id
+
+ await prisma.imageAssetRendition.create({
+ data: {
+ source: renditionKey,
+ format,
+ isPrimary,
+ isAutoGenerated: true,
+ width: size.width,
+ height: size.height,
+ assetId,
+ },
+ })
+
+ if (isPrimary) {
+ await scheduleTapestryThumbnailGeneration(item.tapestryId)
+ }
+
+ console.log(`[${item.id}] Done.`)
+ return output
+ } catch (error) {
+ console.error('Error while generating item thumbnail', error)
+ }
+}
+
+const LEVELS_OF_DETAIL = [256, 512, 1024, 2048, 4096]
+
+function hasRenditionAtLOD(renditions: ImageAssetRendition[], lod: number) {
+ return renditions.some(
+ ({ width, height }) =>
+ width < 1.5 * lod && height < 1.5 * lod && (width > 0.75 * lod || height > 0.75 * lod),
+ )
+}
+
+export async function processItemThumbnail({
+ itemId,
+ forceRegenerate,
+}: JobTypeMap['process-item-thumbnail']) {
+ const loadItem = () =>
+ prisma.item.findUniqueOrThrow({
+ where: { id: itemId },
+ include: { thumbnail: { include: { renditions: true } } },
+ })
+
+ let item = await loadItem()
+
+ // Never regenerate thumbnails if they have not been automatically generated in the first place.
+ // In particular, if the user has manually uploaded a custom thumbnail, we should never delete it
+ // and replace it with an automatically generated one.
+ if (forceRegenerate && item.thumbnail?.renditions.every((r) => r.isAutoGenerated)) {
+ await prisma.imageAsset.delete({ where: { id: item.thumbnail.id } })
+ item = await loadItem()
+ }
+
+ let primaryRendition = item.thumbnail?.renditions.find((r) => r.isPrimary)
+ let primaryRenditionBitmap: Buffer
+
+ if (primaryRendition) {
+ primaryRenditionBitmap = await downloadImageToArrayBuffer(
+ (await parseDBItemSource(primaryRendition.source)).source,
+ )
+ if (primaryRendition.width === 0 || primaryRendition.height === 0) {
+ // In some cases we have invalid dimensions stored in the database. Infer the correct ones from the bitmap.
+ const meta = await sharp(primaryRenditionBitmap, { failOn: 'none' }).metadata()
+ primaryRendition = await prisma.imageAssetRendition.update({
+ where: { id: primaryRendition.id },
+ data: {
+ width: meta.width,
+ height: meta.height,
+ },
+ })
+ }
+ } else {
+ const output = await storeThumbnailRendition(item, true, generatePrimaryThumbnail)
+
+ if (!output) {
+ console.error(`Failed to generate thumbnail for item ${itemId}.`)
+ return
+ }
+
+ // Reload the item so that all foreign keys and relations are loaded properly after any updates that may
+ // have been done in storeThumbnailRendition above.
+ item = await loadItem()
+ primaryRendition = item.thumbnail!.renditions.find((r) => r.isPrimary)!
+ primaryRenditionBitmap = output.data
+ }
+
+ const derivedRenditions = item.thumbnail?.renditions.filter((r) => !r.isPrimary) ?? []
+ const maxPrimaryDim = Math.max(primaryRendition.width, primaryRendition.height)
+
+ for (const lod of LEVELS_OF_DETAIL) {
+ if (maxPrimaryDim > lod && !hasRenditionAtLOD([primaryRendition, ...derivedRenditions], lod)) {
+ await storeThumbnailRendition(item, false, () =>
+ generateThumbnail(primaryRenditionBitmap, {
+ maxDim: lod,
+ optimizeForText: item.type === 'text',
+ }),
+ )
+ }
+ }
+}
diff --git a/server/src/tasks/puppeteer.ts b/server/src/tasks/puppeteer.ts
deleted file mode 100644
index 2b35597..0000000
--- a/server/src/tasks/puppeteer.ts
+++ /dev/null
@@ -1,62 +0,0 @@
-import puppeteer, { BrowserContext, Page, ScreenshotOptions } from 'puppeteer'
-import { config } from '../config.js'
-
-// This is the user agent as if the browser was launched with { headless : false }.
-// Vimeo appears to have some sort of filtering (evidently only for public videos) based on the user agent.
-// When the puppeteer browser is launched with { headless: true } (the default) it automatically has "HeadlessChrome"
-// as part of its user agent, which causes Vimeo to block the requests
-const USER_AGENT =
- 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/133.0.0.0 Safari/537.36'
-
-async function inNewBrowserPage(perform: (page: Page, context: BrowserContext) => Promise) {
- const start = Date.now()
- const browser = await puppeteer.launch({ args: config.worker.puppeteerArgs.split(',') })
- const context = await browser.createBrowserContext()
- const page = await context.newPage()
- await page.setUserAgent(USER_AGENT)
-
- try {
- return await perform(page, context)
- } finally {
- try {
- await browser.close()
- } catch (e) {
- console.debug('Error while closing puppeteer browser context', e)
- }
- console.log(`Browser session completed in ${Date.now() - start}ms.`)
- }
-}
-
-export interface ScreenshotConfig extends ScreenshotOptions {
- width: number
- height: number
- timeout?: number
- setupContext?: (context: BrowserContext) => Promise
-}
-
-export async function takeScreenshot(
- url: string,
- { width, height, setupContext, timeout, ...options }: ScreenshotConfig,
-) {
- console.log(`Taking screenshot of ${url} with dimensions ${width}x${height}...`)
- return inNewBrowserPage(async (page, context) => {
- console.log('> Setting up context...')
- await setupContext?.(context)
- console.log('> Configuring viewport...')
- await page.setViewport({ width, height })
- console.log(`> Navigating to ${url}...`)
- await page.goto(url, { timeout: 120_000 })
- try {
- console.log(`> Waiting for network idle...`)
- await page.waitForNetworkIdle({ idleTime: 3000, concurrency: 0, timeout: timeout ?? 60_000 })
- } catch (error) {
- console.warn(
- 'Error while waiting for the page to load before taking a screenshot. ' +
- 'Taking the screenshot anyway, but it may appear broken.',
- error,
- )
- }
- console.log('> Taking screenshot...')
- return page.screenshot(options)
- })
-}
diff --git a/server/src/tasks/s3-cleanup.ts b/server/src/tasks/s3-cleanup.ts
index e5e4808..ee365dc 100644
--- a/server/src/tasks/s3-cleanup.ts
+++ b/server/src/tasks/s3-cleanup.ts
@@ -8,26 +8,22 @@ const PERSISTED_KEYS: string[] = []
export async function s3Cleanup() {
try {
- const thumbnails = await prisma.tapestry.findMany({
+ const tapestries = await prisma.tapestry.findMany({
select: { thumbnail: true },
where: { thumbnail: { not: null } },
})
const items = await prisma.item.findMany({
- select: { source: true, thumbnail: true, customThumbnail: true },
- where: {
- OR: [
- { source: { not: null } },
- { thumbnail: { not: null } },
- { customThumbnail: { not: null } },
- ],
- },
+ select: { source: true },
+ where: { source: { not: null } },
+ })
+ const imageAssetsRenditions = await prisma.imageAssetRendition.findMany({
+ select: { source: true },
})
const allS3Keys = new Set([
- ...thumbnails.map((t) => t.thumbnail!),
+ ...tapestries.map((t) => t.thumbnail!),
+ ...imageAssetsRenditions.map((r) => r.source),
...compact(items.map((i) => i.source)),
- ...compact(items.map((i) => i.thumbnail)),
- ...compact(items.map((i) => i.customThumbnail)),
...PERSISTED_KEYS,
])
diff --git a/server/src/tasks/thumbnail-generators/image.ts b/server/src/tasks/thumbnail-generators/image.ts
new file mode 100644
index 0000000..889eef7
--- /dev/null
+++ b/server/src/tasks/thumbnail-generators/image.ts
@@ -0,0 +1,49 @@
+import sharp from 'sharp'
+import { ThumbnailRenditionOutput } from '.'
+import { downloadImageToArrayBuffer } from '../utils'
+
+interface DownscaleConfig {
+ maxDim: number
+ optimizeForText?: boolean
+}
+
+export async function generateThumbnail(
+ inputBuffer: Buffer,
+ downscale?: DownscaleConfig,
+): Promise {
+ const output = sharp(inputBuffer, { failOn: 'none' }).rotate()
+
+ if (downscale) {
+ output.gamma().resize({
+ width: downscale.maxDim,
+ height: downscale.maxDim,
+ fit: 'inside',
+ withoutEnlargement: true,
+ fastShrinkOnLoad: false,
+ })
+ if (downscale.optimizeForText) {
+ output.sharpen({ sigma: 0.5 })
+ }
+ }
+
+ output.webp({ lossless: true, effort: 6 })
+
+ const { data, info } = await output.toBuffer({ resolveWithObject: true })
+
+ return {
+ data,
+ format: 'webp',
+ extension: 'webp',
+ size: {
+ width: info.width,
+ height: info.height,
+ },
+ }
+}
+
+export async function generateImageThumbnail(
+ imageUrl: string,
+ downscale: DownscaleConfig = { maxDim: 1024 },
+): Promise {
+ return generateThumbnail(await downloadImageToArrayBuffer(imageUrl), downscale)
+}
diff --git a/server/src/tasks/thumbnail-generators/index.ts b/server/src/tasks/thumbnail-generators/index.ts
new file mode 100644
index 0000000..a6a50f9
--- /dev/null
+++ b/server/src/tasks/thumbnail-generators/index.ts
@@ -0,0 +1,49 @@
+import { Size } from 'tapestry-core/src/data-format/schemas/common'
+import { prisma } from '../../db.js'
+import { Item } from '@prisma/client'
+import { parseDBItemSource } from '../../transformers/item.js'
+import { generateVideoThumbnail } from './video.js'
+import { generatePDFThumbnail } from './pdf.js'
+import { generateImageThumbnail } from './image.js'
+import { generateWebpageThumbnail, generateYoutubeThumbnail } from './webpage.js'
+import { generateTapestryItemThumbnail } from './tapestry.js'
+
+export interface ThumbnailRenditionOutput {
+ data: Buffer
+ extension: string
+ format: string
+ size: Size
+}
+
+const MIN_THUMBNAIL_SIZE = 600
+
+export async function generatePrimaryThumbnail(item: Item) {
+ if (item.type === 'pdf' || item.type === 'video' || item.type === 'image') {
+ const source = (await parseDBItemSource(item.source!)).source
+ // We cannot make thumbnails for blob URLs. The thumbnail creation job should be re-triggered
+ // when the source is changed.
+ if (source.startsWith('blob:')) return
+
+ const thumbWidth = Math.max(MIN_THUMBNAIL_SIZE, item.width)
+ if (item.type === 'pdf') {
+ return generatePDFThumbnail(source, item.defaultPage ?? 1, thumbWidth)
+ }
+ if (item.type === 'video') {
+ return generateVideoThumbnail(source, item.startTime || undefined, thumbWidth)
+ }
+ return generateImageThumbnail(source, { maxDim: thumbWidth })
+ }
+
+ if (item.type === 'webpage') {
+ const { width, height, source, webpageType } = item
+
+ if (webpageType === 'youtube') {
+ return generateYoutubeThumbnail(source!)
+ }
+
+ return generateWebpageThumbnail(source!, { width, height, timeout: 120_000 })
+ }
+
+ const tapestry = await prisma.tapestry.findUniqueOrThrow({ where: { id: item.tapestryId } })
+ return generateTapestryItemThumbnail(tapestry.id, item.id, tapestry.ownerId, item)
+}
diff --git a/server/src/tasks/thumbnail-generators/pdf.ts b/server/src/tasks/thumbnail-generators/pdf.ts
new file mode 100644
index 0000000..66dfc18
--- /dev/null
+++ b/server/src/tasks/thumbnail-generators/pdf.ts
@@ -0,0 +1,51 @@
+import { spawn } from 'child_process'
+import { ThumbnailRenditionOutput } from './index'
+import { generateThumbnail } from './image'
+
+export async function generatePDFThumbnail(
+ src: string,
+ page: number,
+ width: number,
+): Promise {
+ const res = await fetch(src)
+ if (!res.ok) throw new Error(`Fetch failed: ${res.status} ${res.statusText}`)
+
+ const contentType = res.headers.get('content-type') || ''
+ if (!contentType.toLowerCase().startsWith('application/pdf')) {
+ throw new Error(`URL did not return a pdf (content-type: ${contentType || 'unknown'})`)
+ }
+
+ const inputBuffer = Buffer.from(await res.arrayBuffer())
+
+ return new Promise((resolve, reject) => {
+ // prettier-ignore
+ const magickArgs = [
+ '-limit', 'memory', '256MiB',
+ '-limit', 'map', '512MiB',
+ '-limit', 'area', '100MP',
+ '-density', '300',
+ `pdf:-[${page - 1}]`,
+ '-background', 'white',
+ '-alpha', 'off',
+ '-resize', `${width}x`,
+ '-quality', '75',
+ 'jpg:-',
+ ]
+
+ const proc = spawn('magick', magickArgs, { stdio: ['pipe', 'pipe', 'pipe'] })
+ const chunks: Buffer[] = []
+ let err = ''
+
+ proc.stdout.on('data', (d: Buffer) => chunks.push(d))
+ proc.stderr.on('data', (d: Buffer) => (err += d.toString()))
+
+ proc.on('error', reject)
+ proc.on('close', (code) => {
+ if (code !== 0) return reject(new Error(`magick exited ${code}: ${err}`))
+
+ generateThumbnail(Buffer.concat(chunks)).then(resolve, reject)
+ })
+
+ proc.stdin.end(inputBuffer)
+ })
+}
diff --git a/server/src/tasks/thumbnail-generators/tapestry.ts b/server/src/tasks/thumbnail-generators/tapestry.ts
new file mode 100644
index 0000000..9209cab
--- /dev/null
+++ b/server/src/tasks/thumbnail-generators/tapestry.ts
@@ -0,0 +1,66 @@
+/* eslint-disable @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access */
+import { URL } from 'url'
+import { config } from '../../config.js'
+import { createJWT } from '../../auth/tokens.js'
+import { REFRESH_TOKEN_COOKIE_NAME } from '../../auth/index.js'
+import { ScreenshotConfig, takeScreenshot } from './webpage.js'
+import { Size } from 'tapestry-core/src/data-format/schemas/common.js'
+import { ThumbnailRenditionOutput } from './index.js'
+import { generateThumbnail } from './image.js'
+
+export async function takeTapestryScreenshot(
+ tapestryPath: string,
+ userId: string,
+ options: ScreenshotConfig,
+) {
+ return takeScreenshot(`${config.server.viewerUrl}${tapestryPath}`, {
+ setupContext: (context) =>
+ context.setCookie({
+ domain: new URL(config.server.externalUrl).host,
+ name: REFRESH_TOKEN_COOKIE_NAME,
+ value: createJWT({ userId }, '10m'),
+ expires: -1, // Session cookie
+ httpOnly: true,
+ secure: true,
+ }),
+ ...options,
+ })
+}
+
+export async function generateTapestryItemThumbnail(
+ tapestryId: string,
+ itemId: string,
+ ownerId: string,
+ { width, height }: Size,
+): Promise {
+ const tapestryPath = `/t/${tapestryId}?focus=${itemId}&deopt=1`
+ const elementSelector = `[data-model-id="${itemId}"]`
+ const screenshot = await takeTapestryScreenshot(tapestryPath, ownerId, {
+ // Deactivate the tapestry item first, so that its border disappears
+ interact: async (page) => {
+ // Hide everything else around it
+ await page.evaluate((elementSelector) => {
+ // @ts-expect-error This will be executed in a browser context
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
+ const global = window
+ // Deactivate the focused element
+ global.postMessage({ type: 'deactivate' })
+ global.document.documentElement.style.background = 'transparent'
+ global.document.body.style.background = 'transparent'
+ global.document
+ .querySelectorAll(`.pixi-container, [data-model-id]:not(${elementSelector})`)
+ .forEach((elem: { style: { display: string } }) => {
+ elem.style.display = 'none'
+ })
+ }, elementSelector)
+ },
+ elementSelector,
+ // The browser window should be larger than the required element size in order to accommodate
+ // for the tapestry controls around it.
+ width: width + 100,
+ height: height + 300,
+ omitBackground: true,
+ type: 'png',
+ })
+ return generateThumbnail(Buffer.from(screenshot), { maxDim: Math.max(width, height) })
+}
diff --git a/server/src/tasks/thumbnail-generators/video.ts b/server/src/tasks/thumbnail-generators/video.ts
new file mode 100644
index 0000000..3b8e03f
--- /dev/null
+++ b/server/src/tasks/thumbnail-generators/video.ts
@@ -0,0 +1,59 @@
+import { spawn } from 'node:child_process'
+import { unlink } from 'node:fs'
+import { noop } from 'lodash-es'
+import { downloadToTempFile } from '../utils'
+import { ThumbnailRenditionOutput } from '.'
+import { generateThumbnail } from './image'
+
+function extractVideoThumbnailFromFile(
+ filePath: string,
+ startTime = 1,
+ width = 320,
+): Promise {
+ return new Promise((resolve, reject) => {
+ // prettier-ignore
+ const args = [
+ "-hide_banner",
+ "-loglevel", "error",
+ "-nostdin",
+ "-ss", String(startTime),
+ "-i", filePath,
+ "-frames:v", "1",
+ "-an", "-sn", "-dn",
+ // scale with aspect preserved; -2 makes height even
+ "-vf", `scale=${width}:-2:flags=lanczos`,
+ "-f", "image2pipe",
+ "-vcodec", "mjpeg",
+ "pipe:1",
+ ];
+
+ const proc = spawn('ffmpeg', args, { stdio: ['ignore', 'pipe', 'pipe'] })
+
+ const chunks: Buffer[] = []
+ let err = ''
+
+ proc.stdout.on('data', (d: Buffer) => chunks.push(d))
+ proc.stderr.on('data', (d: Buffer) => (err += d.toString('utf8')))
+
+ proc.on('error', reject)
+ proc.on('close', (code) => {
+ if (code !== 0) return reject(new Error(`ffmpeg exited ${code}: ${err}`))
+ resolve(Buffer.concat(chunks))
+ })
+ })
+}
+
+export async function generateVideoThumbnail(
+ videoUrl: string,
+ startTime: number | undefined,
+ width: number,
+): Promise {
+ let tmpFile = ''
+ try {
+ tmpFile = await downloadToTempFile(videoUrl)
+ const frame = await extractVideoThumbnailFromFile(tmpFile, startTime, width)
+ return generateThumbnail(frame)
+ } finally {
+ unlink(tmpFile, noop)
+ }
+}
diff --git a/server/src/tasks/thumbnail-generators/webpage.ts b/server/src/tasks/thumbnail-generators/webpage.ts
new file mode 100644
index 0000000..932545c
--- /dev/null
+++ b/server/src/tasks/thumbnail-generators/webpage.ts
@@ -0,0 +1,114 @@
+import axios from 'axios'
+import { OEmbed } from 'tapestry-core/src/oembed.js'
+import { WEB_SOURCE_PARSERS } from 'tapestry-core/src/web-sources/index.js'
+import { generateThumbnail } from './image'
+import puppeteer, { BrowserContext, Page, ScreenshotOptions } from 'puppeteer'
+import { config } from '../../config.js'
+import { downloadImageToArrayBuffer } from '../utils'
+
+// This is the user agent as if the browser was launched with { headless : false }.
+// Vimeo appears to have some sort of filtering (evidently only for public videos) based on the user agent.
+// When the puppeteer browser is launched with { headless: true } (the default) it automatically has "HeadlessChrome"
+// as part of its user agent, which causes Vimeo to block the requests
+const USER_AGENT =
+ 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/133.0.0.0 Safari/537.36'
+
+async function inNewBrowserPage(perform: (page: Page, context: BrowserContext) => Promise) {
+ const start = Date.now()
+ const browser = await puppeteer.launch({ args: config.worker.puppeteerArgs.split(',') })
+ const context = await browser.createBrowserContext()
+ const page = await context.newPage()
+ await page.setUserAgent({ userAgent: USER_AGENT })
+
+ try {
+ return await perform(page, context)
+ } finally {
+ try {
+ await browser.close()
+ } catch (e) {
+ console.debug('Error while closing puppeteer browser context', e)
+ }
+ console.log(`Browser session completed in ${Date.now() - start}ms.`)
+ }
+}
+
+export interface ScreenshotConfig extends ScreenshotOptions {
+ width: number
+ height: number
+ timeout?: number
+ elementSelector?: string
+ setupContext?: (context: BrowserContext) => Promise
+ interact?: (page: Page) => Promise
+}
+
+export async function takeScreenshot(
+ url: string,
+ { width, height, setupContext, interact, timeout, elementSelector, ...options }: ScreenshotConfig,
+) {
+ console.log(`Taking screenshot of ${url} with dimensions ${width}x${height}...`)
+ return inNewBrowserPage(async (page, context) => {
+ console.log('> Setting up context...')
+ await setupContext?.(context)
+ console.log('> Configuring viewport...')
+ await page.setViewport({ width, height, deviceScaleFactor: 2 })
+ console.log(`> Navigating to ${url}...`)
+ await page.goto(url, { timeout: 120_000 })
+ try {
+ console.log(`> Waiting for network idle...`)
+ await page.waitForNetworkIdle({ idleTime: 3000, concurrency: 0, timeout: timeout ?? 60_000 })
+ console.log(`> Waiting for fonts to load...`)
+ // @ts-expect-error The following expression will be evaluated in the browser context
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-return, @typescript-eslint/no-unsafe-member-access
+ await page.evaluate(() => document.fonts.ready)
+ } catch (error) {
+ console.warn(
+ 'Error while waiting for the page to load before taking a screenshot. ' +
+ 'Taking the screenshot anyway, but it may appear broken.',
+ error,
+ )
+ }
+ console.log('> Taking screenshot...')
+
+ const elementHandle = elementSelector
+ ? await page.waitForSelector(elementSelector, { visible: true })
+ : null
+
+ await interact?.(page)
+
+ return elementHandle ? elementHandle.screenshot(options) : page.screenshot(options)
+ })
+}
+
+export async function generateWebpageThumbnail(url: string, options: ScreenshotConfig) {
+ const screenshot = await takeScreenshot(url, options)
+ return generateThumbnail(Buffer.from(screenshot))
+}
+
+export async function generateYoutubeThumbnail(embedUrl: string) {
+ const videoId = WEB_SOURCE_PARSERS.youtube.getVideoId(embedUrl)
+ const urlsToTest = ['maxresdefault', 'hqdefault'].map(
+ (variant) => `https://i.ytimg.com/vi/${videoId}/${variant}.jpg`,
+ )
+
+ for (const url of urlsToTest) {
+ try {
+ const arrayBuffer = await downloadImageToArrayBuffer(url)
+ return generateThumbnail(arrayBuffer)
+ } catch (error) {
+ console.warn(`Failed to fetch YouTube thumbnail ${url}:`, error)
+ }
+ }
+
+ // If we couldn't find a thumbnail from known URL formats, try the oEmbed API
+ const { data: oembed } = await axios.get(
+ `https://www.youtube.com/oembed?url=${WEB_SOURCE_PARSERS.youtube.getWatchUrl(embedUrl)}`,
+ )
+ const { type, thumbnail_url: thumbnailUrl } = oembed
+ if (type !== 'video' || !thumbnailUrl) return
+
+ const { data: blob } = await axios.get(thumbnailUrl, {
+ responseType: 'arraybuffer',
+ })
+
+ return generateThumbnail(Buffer.from(blob))
+}
diff --git a/server/src/tasks/utils.ts b/server/src/tasks/utils.ts
index 068a2c7..2025f06 100644
--- a/server/src/tasks/utils.ts
+++ b/server/src/tasks/utils.ts
@@ -1,13 +1,60 @@
-import { exec as nodeExec } from 'child_process'
-
-export function exec(cmd: string) {
- return new Promise((resolve, reject) => {
- nodeExec(cmd, { maxBuffer: 50 * 1024 * 1024 }, (error, stdout) => {
- if (error) {
- reject(error)
- return
- }
- resolve(stdout)
- })
- })
+import { createWriteStream } from 'node:fs'
+import { tmpdir } from 'node:os'
+import { extname, join } from 'node:path'
+import { randomUUID } from 'node:crypto'
+import { Readable } from 'node:stream'
+import { finished } from 'node:stream/promises'
+
+export interface DownloadOpts {
+ timeoutMs?: number
+ maxBytes?: number
+ allowedContentTypePrefixes?: string[] // e.g. "video/"
+}
+
+export async function downloadImageToArrayBuffer(url: string) {
+ const res = await fetch(url, { redirect: 'follow' })
+ if (!res.ok) throw new Error(`Fetch failed: ${res.status} ${res.statusText}`)
+
+ const contentType = res.headers.get('content-type') || ''
+ if (!contentType.startsWith('image/')) {
+ throw new Error(`URL did not return an image (content-type: ${contentType || 'unknown'})`)
+ }
+
+ return Buffer.from(await res.arrayBuffer())
+}
+
+export async function downloadToTempFile(urlStr: string, opts: DownloadOpts = {}) {
+ const url = new URL(urlStr)
+
+ const {
+ timeoutMs = 60_000,
+ maxBytes = 200 * 1024 * 1024, // 200MB default limit
+ allowedContentTypePrefixes,
+ } = opts
+
+ const abortController = new AbortController()
+ const timeoutId = setTimeout(() => abortController.abort(), timeoutMs)
+
+ const res = await fetch(url, {
+ redirect: 'follow',
+ signal: abortController.signal,
+ }).finally(() => clearTimeout(timeoutId))
+
+ if (!res.ok || !res.body) throw new Error(`Fetch failed: ${res.status} ${res.statusText}`)
+
+ const contentType = (res.headers.get('content-type') || '').toLowerCase()
+ if (allowedContentTypePrefixes?.every((prefix) => !contentType.startsWith(prefix))) {
+ throw new Error(`Not a matching content-type: ${contentType || 'unknown'}`)
+ }
+
+ const contentLength = res.headers.get('content-length')
+ if (contentLength && Number(contentLength) > maxBytes) {
+ throw new Error(`Content too large (content-length=${contentLength})`)
+ }
+
+ const extension = extname(url.pathname) || '.bin'
+ const filePath = join(tmpdir(), `file-${randomUUID()}${extension}`)
+ await finished(Readable.fromWeb(res.body).pipe(createWriteStream(filePath)))
+
+ return filePath
}
diff --git a/server/src/tasks/worker.ts b/server/src/tasks/worker.ts
index 14bbac0..7ffcad7 100644
--- a/server/src/tasks/worker.ts
+++ b/server/src/tasks/worker.ts
@@ -2,15 +2,15 @@ import { Job, Worker } from 'bullmq'
import { BULLMQ_REDIS_BASE_OPTIONS, JobName, JobTypeMap, QUEUE_NAME } from './index.js'
import { generateTapestryThumbnail } from './generate-tapestry-thumbnail.js'
import { s3Cleanup } from './s3-cleanup.js'
-import { generateItemThumbnail } from './generate-item-thumbnail.js'
+import { processItemThumbnail } from './process-item-thumbnail.js'
import { createTapestry } from './create-tapestry.js'
async function processTask(job: Job) {
switch (job.name) {
case 'generate-tapestry-thumbnail':
return generateTapestryThumbnail(job.data as JobTypeMap['generate-tapestry-thumbnail'])
- case 'generate-item-thumbnail':
- return generateItemThumbnail(job.data as JobTypeMap['generate-item-thumbnail'])
+ case 'process-item-thumbnail':
+ return processItemThumbnail(job.data as JobTypeMap['process-item-thumbnail'])
case 's3-cleanup':
return s3Cleanup()
case 'create-tapestry':
diff --git a/server/src/transformers/index.ts b/server/src/transformers/index.ts
index 668bff4..988188e 100644
--- a/server/src/transformers/index.ts
+++ b/server/src/transformers/index.ts
@@ -14,7 +14,7 @@ import { TapestryCreateJobDto } from 'tapestry-shared/src/data-transfer/resource
import { userDbToDto } from './user.js'
import { tapestryDbToDto } from './tapestry.js'
import { get, identity, set, pick } from 'lodash-es'
-import { itemDbToDto } from './item.js'
+import { imageAssetRenditionDbToDto, itemDbToDto } from './item.js'
import { relDbToDto } from './rel.js'
import { commentDbToDto } from './comment.js'
import { TapestryInteractionDto } from 'tapestry-shared/src/data-transfer/resources/dtos/tapestry-interaction.js'
@@ -30,6 +30,10 @@ import { OneOrMore } from 'tapestry-core/src/utils.js'
import { UserSecretDto } from 'tapestry-shared/src/data-transfer/resources/dtos/user-secret.js'
import { TapestryBookmarkDto } from 'tapestry-shared/src/data-transfer/resources/dtos/tapestry-bookmark.js'
import { userToPublicProfileDto } from 'tapestry-shared/src/utils.js'
+import {
+ ImageAssetDto,
+ ImageAssetRenditionDto,
+} from 'tapestry-shared/src/data-transfer/resources/dtos/image-assets.js'
interface DtoMap {
Tapestry: { default: TapestryDto }
@@ -51,6 +55,8 @@ interface DtoMap {
PresentationStep: { default: PresentationStepDto }
UserSecret: { default: UserSecretDto }
TapestryBookmark: { default: TapestryBookmarkDto }
+ ImageAsset: { default: ImageAssetDto }
+ ImageAssetRendition: { default: ImageAssetRenditionDto }
}
type ModelSerializer = (
@@ -87,6 +93,8 @@ const MODEL_SERIALIZERS: ModelSerializersMap = {
PresentationStep: { default: presentationStepDbToDto },
UserSecret: { default: identity },
TapestryBookmark: { default: identity },
+ ImageAsset: { default: identity },
+ ImageAssetRendition: { default: imageAssetRenditionDbToDto },
}
type RelationViews = {
diff --git a/server/src/transformers/item.ts b/server/src/transformers/item.ts
index e0126d3..2d9140a 100644
--- a/server/src/transformers/item.ts
+++ b/server/src/transformers/item.ts
@@ -1,10 +1,11 @@
-import { Item } from '@prisma/client'
+import { ImageAssetRendition, Item } from '@prisma/client'
import { get, set } from 'lodash-es'
import { HexColor } from 'tapestry-core/src/data-format/schemas/common.js'
import { ItemDto } from 'tapestry-shared/src/data-transfer/resources/dtos/item.js'
import { extractInternallyHostedS3Key, s3Service } from '../services/s3-service.js'
import { isHTTPURL } from 'tapestry-core/src/utils.js'
import { MEDIA_ITEM_TYPES } from 'tapestry-core/src/data-format/schemas/item.js'
+import { ImageAssetRenditionDto } from 'tapestry-shared/src/data-transfer/resources/dtos/image-assets.js'
export async function parseDBItemSource(source: string) {
const internallyHosted = !isHTTPURL(source) && !source.startsWith('blob:')
@@ -12,6 +13,24 @@ export async function parseDBItemSource(source: string) {
return { source, internallyHosted }
}
+export async function imageAssetRenditionDbToDto(
+ dbRendition: ImageAssetRendition,
+): Promise {
+ return Promise.resolve({
+ id: dbRendition.id,
+ createdAt: dbRendition.createdAt,
+ updatedAt: dbRendition.updatedAt,
+ source: (await parseDBItemSource(dbRendition.source)).source,
+ format: dbRendition.format,
+ isPrimary: dbRendition.isPrimary,
+ isAutoGenerated: dbRendition.isAutoGenerated,
+ size: {
+ width: dbRendition.width,
+ height: dbRendition.height,
+ },
+ })
+}
+
export async function itemDbToDto(dbItem: Item): Promise {
const commonProps = {
id: dbItem.id,
@@ -53,23 +72,7 @@ export async function itemDbToDto(dbItem: Item): Promise {
}
}
- const thumbnail = dbItem.thumbnail
- ? {
- source: await s3Service.getReadObjectUrl(dbItem.thumbnail),
- size: {
- width: dbItem.thumbnailWidth!,
- height: dbItem.thumbnailHeight!,
- },
- }
- : undefined
-
- const commonMediaItemProps = {
- ...(await parseDBItemSource(dbItem.source!)),
- thumbnail,
- customThumbnail: dbItem.customThumbnail
- ? (await parseDBItemSource(dbItem.customThumbnail)).source
- : null,
- }
+ const commonMediaItemProps = await parseDBItemSource(dbItem.source!)
if (type === 'video' || type === 'audio') {
return {
@@ -123,14 +126,11 @@ const DB_TO_DTO_FIELD_MAP: Record = {
text: 'text',
backgroundColor: 'backgroundColor',
source: 'source',
- thumbnail: 'thumbnail.source',
- thumbnailWidth: 'thumbnail.size.width',
- thumbnailHeight: 'thumbnail.size.height',
+ thumbnailId: 'thumbnailId',
startTime: 'startTime',
stopTime: 'stopTime',
groupId: 'groupId',
notes: 'notes',
- customThumbnail: 'customThumbnail',
defaultPage: 'defaultPage',
actionType: 'actionType',
action: 'action',
@@ -165,7 +165,6 @@ export function itemDtoToDb(
}
dbItem.source = extractInternallyHostedS3Key(dbItem.source) ?? dbItem.source
- dbItem.thumbnail = extractInternallyHostedS3Key(dbItem.thumbnail) ?? dbItem.thumbnail
return dbItem as Omit
-
}
diff --git a/shared/src/data-transfer/resources/dtos/image-assets.ts b/shared/src/data-transfer/resources/dtos/image-assets.ts
new file mode 100644
index 0000000..1d37087
--- /dev/null
+++ b/shared/src/data-transfer/resources/dtos/image-assets.ts
@@ -0,0 +1,5 @@
+import { BaseResourceDto } from './common.js'
+import { ImageAsset, ImageAssetRendition } from 'tapestry-core/src/data-format/schemas/item.js'
+
+export interface ImageAssetDto extends ImageAsset, BaseResourceDto {}
+export interface ImageAssetRenditionDto extends ImageAssetRendition, BaseResourceDto {}
diff --git a/shared/src/data-transfer/resources/dtos/item.ts b/shared/src/data-transfer/resources/dtos/item.ts
index f8a895e..7123b15 100644
--- a/shared/src/data-transfer/resources/dtos/item.ts
+++ b/shared/src/data-transfer/resources/dtos/item.ts
@@ -6,11 +6,13 @@ import {
AudioItem,
BookItem,
ImageItem,
+ Item,
PdfItem,
TextItem,
VideoItem,
WebpageItem,
} from 'tapestry-core/src/data-format/schemas/item.js'
+import { Size } from 'tapestry-core/src/data-format/schemas/common.js'
interface BaseItemDto extends BaseResourceDto {
tapestry?: TapestryDto | null
@@ -21,28 +23,33 @@ interface BaseMediaItemDto extends BaseItemDto {
internallyHosted: boolean
}
-type ItemReadonlyProps = keyof BaseItemDto
+interface ThumbnailUpdate {
+ thumbnail?: {
+ source: string
+ size: Size
+ } | null
+}
+
+type ItemReadonlyProps = keyof BaseItemDto | 'thumbnail'
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
type NonNullishType = { type: {} }
+type CreateDto = Omit>
+type CreateInTapestryDto = Omit>
+type UpdateDto = Omit, ItemReadonlyProps> &
+ NonNullishType &
+ ThumbnailUpdate
+
export interface TextItemDto extends TextItem, BaseItemDto {}
-export type TextItemCreateDto = Omit>
-export type TextItemCreateInTapestryDto = Omit>
-export type TextItemUpdateDto = Omit, ItemReadonlyProps> & NonNullishType
+export type TextItemCreateDto = CreateDto
+export type TextItemCreateInTapestryDto = CreateInTapestryDto
+export type TextItemUpdateDto = UpdateDto
export interface ActionButtonItemDto extends ActionButtonItem, BaseItemDto {}
-
-export type ActionButtonItemCreateDto = Omit<
- ActionButtonItemDto,
- Exclude
->
-export type ActionButtonItemCreateInTapestryDto = Omit<
- ActionButtonItemDto,
- Exclude
->
-export type ActionButtonItemUpdateDto = Omit, ItemReadonlyProps> &
- NonNullishType
+export type ActionButtonItemCreateDto = CreateDto
+export type ActionButtonItemCreateInTapestryDto = CreateInTapestryDto
+export type ActionButtonItemUpdateDto = UpdateDto
export interface AudioItemDto extends AudioItem, BaseMediaItemDto {}
export interface VideoItemDto extends VideoItem, BaseMediaItemDto {}
@@ -59,7 +66,7 @@ export type MediaItemDto =
| VideoItemDto
| WebpageItemDto
-type MediaItemReadonlyProps = ItemReadonlyProps | 'thumbnail' | 'internallyHosted'
+type MediaItemReadonlyProps = ItemReadonlyProps | 'internallyHosted'
type BaseMediaItemWriteProps = MediaItemDto & {
skipSourceResolution?: boolean
@@ -81,7 +88,8 @@ export type MediaItemUpdateDto = DistributiveOmit<
Partial,
MediaItemReadonlyProps
> &
- NonNullishType
+ NonNullishType &
+ ThumbnailUpdate
export type ItemDto = TextItemDto | ActionButtonItemDto | MediaItemDto
export type ItemCreateDto = TextItemCreateDto | ActionButtonItemCreateDto | MediaItemCreateDto
diff --git a/shared/src/data-transfer/resources/index.ts b/shared/src/data-transfer/resources/index.ts
index 324c17a..6c71d7c 100644
--- a/shared/src/data-transfer/resources/index.ts
+++ b/shared/src/data-transfer/resources/index.ts
@@ -90,7 +90,7 @@ import {
const tapestryIncludes = [
'owner',
- 'items',
+ 'items.thumbnail.renditions',
'rels',
'groups',
'userAccess',
diff --git a/shared/src/data-transfer/resources/schemas/item.ts b/shared/src/data-transfer/resources/schemas/item.ts
index 38eeac6..819aa32 100644
--- a/shared/src/data-transfer/resources/schemas/item.ts
+++ b/shared/src/data-transfer/resources/schemas/item.ts
@@ -11,7 +11,7 @@ import {
ImageItemSchema as BaseImageItemSchema,
WebpageItemSchema as BaseWebpageItemSchema,
} from 'tapestry-core/src/data-format/schemas/item.js'
-import { IdentifiableSchema } from 'tapestry-core/src/data-format/schemas/common.js'
+import { IdentifiableSchema, SizeSchema } from 'tapestry-core/src/data-format/schemas/common.js'
const readonlyProps = {
tapestryId: z.string(),
@@ -32,7 +32,17 @@ function constructItemSchemas
>(
...readonlyProps,
...itemProps,
})
- const createItemProps = omit(itemProps, createOmitProps) as P
+ const itemWriteProps = {
+ ...(itemProps as Omit
),
+ thumbnail: z
+ .object({
+ source: z.string(),
+ size: SizeSchema,
+ })
+ .nullish(),
+ }
+ type W = typeof itemWriteProps
+ const createItemProps = omit(itemWriteProps, createOmitProps) as W
const itemCreateSchema = z.object({
...readonlyProps,
...createItemProps,
@@ -45,10 +55,10 @@ function constructItemSchemas
>(
...createItemProps,
})
- const { type, ...otherProps } = itemProps
+ const { type, ...otherProps } = itemWriteProps
const partialProps = z
.object({
- ...(omit(otherProps, createOmitProps) as Omit
),
+ ...(omit(otherProps, createOmitProps) as Omit),
})
.partial()
const itemUpdateSchema = z.object({ ...partialProps.shape, type: type as P['type'] })
diff --git a/shared/src/data-transfer/resources/types.ts b/shared/src/data-transfer/resources/types.ts
index c228fcc..e6c803d 100644
--- a/shared/src/data-transfer/resources/types.ts
+++ b/shared/src/data-transfer/resources/types.ts
@@ -1,5 +1,5 @@
import type { ZodType } from 'zod/v4'
-import type { Path } from 'tapestry-core/src/type-utils.js'
+import type { Prev } from 'tapestry-core/src/type-utils.js'
import {
EmptyObject,
IdParam,
@@ -31,7 +31,23 @@ export interface Request = readonly Path[]
+// This type is very similar to Path except that it skips array indices
+export type Include = [D] extends [never]
+ ? never
+ : R extends Date
+ ? never
+ : R extends unknown[]
+ ? Include
+ : R extends object
+ ? {
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type
+ [K in keyof R]-?: R[K] extends Function
+ ? never
+ : `${Extract}${'' | `.${Include}`}`
+ }[keyof R]
+ : never
+
+export type Includes = readonly Include[]
export interface Endpoint<
Method extends HTTPMethod = HTTPMethod,
diff --git a/shared/src/utils.ts b/shared/src/utils.ts
index a5097d9..43e2818 100644
--- a/shared/src/utils.ts
+++ b/shared/src/utils.ts
@@ -1,4 +1,5 @@
import { PublicUserProfileDto, UserDto } from './data-transfer/resources/dtos/user'
+import { Size } from 'tapestry-core/src/data-format/schemas/common'
export function userToPublicProfileDto(user: UserDto): PublicUserProfileDto {
return {
@@ -11,3 +12,12 @@ export function userToPublicProfileDto(user: UserDto): PublicUserProfileDto {
avatar: user.avatar,
}
}
+
+export function generateItemThumbnailRenditionName(
+ itemId: string,
+ opts: { isPrimary: boolean; isAutoGenerated: boolean; format: string; size: Size },
+) {
+ const autoPrefix = opts.isAutoGenerated ? 'auto' : 'custom'
+ const primaryPrefix = opts.isPrimary ? 'primary' : 'derived'
+ return `${itemId}-${autoPrefix}-${primaryPrefix}-thumbnail-${opts.format}-${opts.size.width}x${opts.size.height}`
+}
diff --git a/viewer/src/components/tapestry/index.tsx b/viewer/src/components/tapestry/index.tsx
index ad6d25b..6608e62 100644
--- a/viewer/src/components/tapestry/index.tsx
+++ b/viewer/src/components/tapestry/index.tsx
@@ -14,6 +14,7 @@ import { TapestryLifecycleController } from 'tapestry-core-client/src/stage/cont
import { GlobalEventsController } from 'tapestry-core-client/src/stage/controller/global-events-controller'
import { ItemController } from 'tapestry-core-client/src/stage/controller/item-controller'
import { ViewportController } from 'tapestry-core-client/src/stage/controller/viewport-controller'
+import { ItemThumbnailController } from 'tapestry-core-client/src/stage/controller/item-thumbnail-controller'
import { TapestryRenderer } from 'tapestry-core-client/src/stage/renderer'
import { idMapToArray } from 'tapestry-core/src/utils'
import { useTapestryStore } from '../../app'
@@ -32,7 +33,7 @@ export function Tapestry({ onBack }: TapestryProps) {
const store = useTapestryStore()
useStageInit(sceneRef, {
- gestureDectorOptions: { scrollGesture: 'pan', dragToPan: store.get('pointerMode') === 'pan' },
+ gestureDetectorOptions: { scrollGesture: 'pan', dragToPan: store.get('pointerMode') === 'pan' },
createPixiApps: async () => [
{
name: 'tapestry',
@@ -44,6 +45,7 @@ export function Tapestry({ onBack }: TapestryProps) {
lifecycleController: (stage) =>
new TapestryLifecycleController(store, stage, {
default: [
+ new ItemThumbnailController(store),
new ViewportController(store, stage),
new (class extends TapestryRenderer {
protected getItems() {
diff --git a/viewer/src/index.css b/viewer/src/index.css
index 4eb42d7..b70218d 100644
--- a/viewer/src/index.css
+++ b/viewer/src/index.css
@@ -43,13 +43,6 @@ body,
transform-origin: 0% 0%;
user-select: none;
-webkit-user-select: none;
-
- /* This is used in order to force the browser (Safari in particular)
- * to draw a compositing layer for the dom-container. Otherwise when the dom-container
- * goes out of the viewport Safari starts drawing layers for every single item in the viewport
- * which causes crashes on iPhones
- */
- will-change: transform;
}
}
diff --git a/viewer/src/services/import-service.ts b/viewer/src/services/import-service.ts
index 716bba4..dfcab7c 100644
--- a/viewer/src/services/import-service.ts
+++ b/viewer/src/services/import-service.ts
@@ -6,6 +6,7 @@ import {
type Entry,
type FileEntry,
} from '@zip.js/zip.js'
+import { compact } from 'lodash-es'
import { Store } from 'tapestry-core-client/src/lib/store'
import { viewModelFromTapestry } from 'tapestry-core-client/src/view-model/utils'
import {
@@ -15,7 +16,8 @@ import {
ROOT_FILE,
} from 'tapestry-core/src/data-format/export'
import { HexColor } from 'tapestry-core/src/data-format/schemas/common'
-import { hasThumbnail, isMediaItem } from 'tapestry-core/src/utils'
+import { MediaItem } from 'tapestry-core/src/data-format/schemas/item'
+import { isMediaItem } from 'tapestry-core/src/utils'
type ExportItem = NonNullable[number]
@@ -59,21 +61,23 @@ export class ImportService {
this.entries.find((e) => e.filename === name) as FileEntry | undefined
private parseItem = async (i: ExportItem) => {
- if (!isMediaItem(i)) {
- return i
+ const item = { ...i }
+
+ if (isMediaItem(i)) {
+ ;(item as MediaItem).source = (await this.toObjectUrl(i.source)) ?? i.source
}
- return {
- ...i,
- source: (await this.toObjectUrl(i.source)) ?? i.source,
- thumbnail: hasThumbnail(i)
- ? {
- source: (await this.toObjectUrl(i.thumbnail.source))!,
- size: i.thumbnail.size,
- }
- : undefined,
- customThumbnail: await this.toObjectUrl(i.customThumbnail),
+ item.thumbnail = i.thumbnail && {
+ renditions: compact(
+ await Promise.all(
+ i.thumbnail.renditions.map(async (r) => {
+ const source = await this.toObjectUrl(r.source)
+ return source && { ...r, source }
+ }),
+ ),
+ ),
}
+ return item
}
private async toObjectUrl(url: string | undefined | null) {