authorgravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-02-03 02:37:03-08:00
committergravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-02-04 02:21:47-08:00
log9649288a27c2bda9361572d08e45438ea89a789f
treee46aa60044c6a8d67a4e60b25b6c012ac27738a9
parent24465d155378c4b617a658a27385f1da2e381d90
signature Signed by SSH key SHA256:xbd+BjjhyBfwk7GVoURf9Yx0gzDerHbvYv7SddNWmAs

feat(site): history of japan reanimated


11 files changed, 631 insertions(+), 77 deletions(-)

package-lock.json+153-22
...@@ -4,6 +4,7 @@...@@ -4,6 +4,7 @@
4 "requires": true,4 "requires": true,
5 "packages": {5 "packages": {
6 "": {6 "": {
7 "name": "sitegen",
7 "dependencies": {8 "dependencies": {
8 "@hono/node-server": "1.14.3",9 "@hono/node-server": "1.14.3",
9 "@mdx-js/mdx": "3.1.0",10 "@mdx-js/mdx": "3.1.0",
...@@ -58,6 +59,7 @@...@@ -58,6 +59,7 @@
58 "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.4.tgz",59 "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.4.tgz",
59 "integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==",60 "integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==",
60 "license": "MIT",61 "license": "MIT",
62 "peer": true,
61 "dependencies": {63 "dependencies": {
62 "@babel/code-frame": "^7.27.1",64 "@babel/code-frame": "^7.27.1",
63 "@babel/generator": "^7.28.3",65 "@babel/generator": "^7.28.3",
...@@ -1168,11 +1170,129 @@...@@ -1168,11 +1170,129 @@
1168 "node": ">=10"1170 "node": ">=10"
1169 }1171 }
1170 },1172 },
1171 "node_modules/@svta/common-media-library": {1173 "node_modules/@svta/cml-608": {
1172 "version": "0.12.4",1174 "version": "1.0.1",
1173 "resolved": "https://registry.npmjs.org/@svta/common-media-library/-/common-media-library-0.12.4.tgz",1175 "resolved": "https://registry.npmjs.org/@svta/cml-608/-/cml-608-1.0.1.tgz",
1174 "integrity": "sha512-9EuOoaNmz7JrfGwjsrD9SxF9otU5TNMnbLu1yU4BeLK0W5cDxVXXR58Z89q9u2AnHjIctscjMTYdlqQ1gojTuw==",1176 "integrity": "sha512-Y/Ier9VPUSOBnf0bJqdDyTlPrt4dDB+jk5mYHa1bnD2kcRl8qn7KkW3PRuj4w1aVN+BS2eHmsLxodt7P2hylUg==",
1175 "license": "Apache-2.0"1177 "license": "Apache-2.0",
1178 "engines": {
1179 "node": ">=20"
1180 }
1181 },
1182 "node_modules/@svta/cml-cmcd": {
1183 "version": "1.0.1",
1184 "resolved": "https://registry.npmjs.org/@svta/cml-cmcd/-/cml-cmcd-1.0.1.tgz",
1185 "integrity": "sha512-eox305g+QUJgXqOLVrbgxeQHCgl90ewwQ9O2bIoo7m+hanR8Xswu5CknFnT5qqIbLOHfw80ug+raycoAFHTQ+w==",
1186 "license": "Apache-2.0",
1187 "engines": {
1188 "node": ">=20"
1189 },
1190 "peerDependencies": {
1191 "@svta/cml-cta": "1.0.1",
1192 "@svta/cml-structured-field-values": "1.0.1",
1193 "@svta/cml-utils": "1.0.1"
1194 }
1195 },
1196 "node_modules/@svta/cml-cmsd": {
1197 "version": "1.0.1",
1198 "resolved": "https://registry.npmjs.org/@svta/cml-cmsd/-/cml-cmsd-1.0.1.tgz",
1199 "integrity": "sha512-+nIB8PuSfb/qw+xGaArPhNqPm84tBJUbe3H1DnPL5QUsjSUI7mUIUQwAtRV1ZdEu0+80g9i0op79woB0OIwr/g==",
1200 "license": "Apache-2.0",
1201 "engines": {
1202 "node": ">=20"
1203 },
1204 "peerDependencies": {
1205 "@svta/cml-cta": "1.0.1",
1206 "@svta/cml-structured-field-values": "1.0.1",
1207 "@svta/cml-utils": "1.0.1"
1208 }
1209 },
1210 "node_modules/@svta/cml-cta": {
1211 "version": "1.0.1",
1212 "resolved": "https://registry.npmjs.org/@svta/cml-cta/-/cml-cta-1.0.1.tgz",
1213 "integrity": "sha512-jcXqNIPv26bmFxIOFh8/c3+6WLH4qBjKpq9qTQcggDPoHuV1YBydMsJLOnYPDeK8rNMKcAkFLbnDRvyJthu5yw==",
1214 "license": "Apache-2.0",
1215 "peer": true,
1216 "engines": {
1217 "node": ">=20"
1218 },
1219 "peerDependencies": {
1220 "@svta/cml-structured-field-values": "1.0.1",
1221 "@svta/cml-utils": "1.0.1"
1222 }
1223 },
1224 "node_modules/@svta/cml-dash": {
1225 "version": "1.0.1",
1226 "resolved": "https://registry.npmjs.org/@svta/cml-dash/-/cml-dash-1.0.1.tgz",
1227 "integrity": "sha512-lYnD1I7FUbbQND+xICI+kcRaRXuT+whKk27R8m8me5VMVu2sMsAMc7Yui6l9sxw2cBKt8pSETPYRm/1+n4LZkw==",
1228 "license": "Apache-2.0",
1229 "engines": {
1230 "node": ">=20"
1231 },
1232 "peerDependencies": {
1233 "@svta/cml-utils": "1.0.1"
1234 }
1235 },
1236 "node_modules/@svta/cml-id3": {
1237 "version": "1.0.1",
1238 "resolved": "https://registry.npmjs.org/@svta/cml-id3/-/cml-id3-1.0.1.tgz",
1239 "integrity": "sha512-90fGlL1qRI88CcaB89k6NG6cC3kky4Eu2jwqU4HefqK+S5k2OASUxf8JXkGz+DsdaiY7sh51vGPYdolfBZS7ug==",
1240 "license": "Apache-2.0",
1241 "engines": {
1242 "node": ">=20"
1243 },
1244 "peerDependencies": {
1245 "@svta/cml-utils": "1.0.1"
1246 }
1247 },
1248 "node_modules/@svta/cml-request": {
1249 "version": "1.0.1",
1250 "resolved": "https://registry.npmjs.org/@svta/cml-request/-/cml-request-1.0.1.tgz",
1251 "integrity": "sha512-enL19BuXUjFkDDDF9jdNwUclMNPRsagnjGAetVC7xcmpDMpEx+ZLgsDip6BFNg5p6izSEk/OyujTWW1r8bDNiA==",
1252 "license": "Apache-2.0",
1253 "engines": {
1254 "node": ">=20"
1255 },
1256 "peerDependencies": {
1257 "@svta/cml-utils": "1.0.1",
1258 "@svta/cml-xml": "1.0.1"
1259 }
1260 },
1261 "node_modules/@svta/cml-structured-field-values": {
1262 "version": "1.0.1",
1263 "resolved": "https://registry.npmjs.org/@svta/cml-structured-field-values/-/cml-structured-field-values-1.0.1.tgz",
1264 "integrity": "sha512-Kibciki59Pon3Pn/sl5uyrbJcSpZQDKqdCfDrokBvOdLoqqcd0oFrkEPsZBiuuIODX1CB80612xe8hopeFDyBA==",
1265 "license": "Apache-2.0",
1266 "peer": true,
1267 "engines": {
1268 "node": ">=20"
1269 },
1270 "peerDependencies": {
1271 "@svta/cml-utils": "1.0.1"
1272 }
1273 },
1274 "node_modules/@svta/cml-utils": {
1275 "version": "1.0.1",
1276 "resolved": "https://registry.npmjs.org/@svta/cml-utils/-/cml-utils-1.0.1.tgz",
1277 "integrity": "sha512-kso3curTJfp00I1mKFoBliBApjn4aPE+wF8cPucf7TrSDVWZDeLLuF14ASmUE9m7rnrqTTK4878VvmXaXcCCfQ==",
1278 "license": "Apache-2.0",
1279 "peer": true,
1280 "engines": {
1281 "node": ">=20"
1282 }
1283 },
1284 "node_modules/@svta/cml-xml": {
1285 "version": "1.0.1",
1286 "resolved": "https://registry.npmjs.org/@svta/cml-xml/-/cml-xml-1.0.1.tgz",
1287 "integrity": "sha512-11LkJa5kDEcsRMWkVI1ABH3KLCxGoiSVe4kQ293ItVj8ncTTQ7htmCGiJDjS+Cmy35UgF3e/vc0ysJIiWRTx2g==",
1288 "license": "Apache-2.0",
1289 "peer": true,
1290 "engines": {
1291 "node": ">=20"
1292 },
1293 "peerDependencies": {
1294 "@svta/cml-utils": "1.0.1"
1295 }
1176 },1296 },
1177 "node_modules/@tootallnate/quickjs-emscripten": {1297 "node_modules/@tootallnate/quickjs-emscripten": {
1178 "version": "0.23.0",1298 "version": "0.23.0",
...@@ -1271,6 +1391,7 @@...@@ -1271,6 +1391,7 @@
1271 "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",1391 "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
1272 "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",1392 "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
1273 "license": "MIT",1393 "license": "MIT",
1394 "peer": true,
1274 "bin": {1395 "bin": {
1275 "acorn": "bin/acorn"1396 "acorn": "bin/acorn"
1276 },1397 },
...@@ -1530,6 +1651,7 @@...@@ -1530,6 +1651,7 @@
1530 }1651 }
1531 ],1652 ],
1532 "license": "MIT",1653 "license": "MIT",
1654 "peer": true,
1533 "dependencies": {1655 "dependencies": {
1534 "baseline-browser-mapping": "^2.8.9",1656 "baseline-browser-mapping": "^2.8.9",
1535 "caniuse-lite": "^1.0.30001746",1657 "caniuse-lite": "^1.0.30001746",
...@@ -1778,12 +1900,18 @@...@@ -1778,12 +1900,18 @@
1778 "license": "MIT"1900 "license": "MIT"
1779 },1901 },
1780 "node_modules/dashjs": {1902 "node_modules/dashjs": {
1781 "version": "5.0.3",1903 "version": "5.1.1",
1782 "resolved": "https://registry.npmjs.org/dashjs/-/dashjs-5.0.3.tgz",1904 "resolved": "https://registry.npmjs.org/dashjs/-/dashjs-5.1.1.tgz",
1783 "integrity": "sha512-TXndNnCUjFjF2nYBxDVba+hWRpVkadkQ8flLp7kHkem+5+wZTfRShJCnVkPUosmjS0YPE9fVNLbYPJxHBeQZvA==",1905 "integrity": "sha512-BzNXlUgzEjhuZ5M5hlSp1qIyQHZ7NpXAR0loP9DAAFVZj/ntL1DHeZ7qp/L3bvI4rq50X5indkAZQ3zEHWJoCA==",
1784 "license": "BSD-3-Clause",1906 "license": "BSD-3-Clause",
1785 "dependencies": {1907 "dependencies": {
1786 "@svta/common-media-library": "^0.12.4",1908 "@svta/cml-608": "1.0.1",
1909 "@svta/cml-cmcd": "1.0.1",
1910 "@svta/cml-cmsd": "1.0.1",
1911 "@svta/cml-dash": "1.0.1",
1912 "@svta/cml-id3": "1.0.1",
1913 "@svta/cml-request": "1.0.1",
1914 "@svta/cml-xml": "1.0.1",
1787 "bcp-47-match": "^2.0.3",1915 "bcp-47-match": "^2.0.3",
1788 "bcp-47-normalize": "^2.3.0",1916 "bcp-47-normalize": "^2.3.0",
1789 "codem-isoboxer": "0.3.10",1917 "codem-isoboxer": "0.3.10",
...@@ -1868,9 +1996,9 @@...@@ -1868,9 +1996,9 @@
1868 }1996 }
1869 },1997 },
1870 "node_modules/devalue": {1998 "node_modules/devalue": {
1871 "version": "5.3.2",1999 "version": "5.6.2",
1872 "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.3.2.tgz",2000 "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.6.2.tgz",
1873 "integrity": "sha512-UDsjUbpQn9kvm68slnrs+mfxwFkIflOhkanmyabZ8zOYk8SMEIbJ3TK+88g70hSIeytu4y18f0z/hYHMTrXIWw==",2001 "integrity": "sha512-nPRkjWzzDQlsejL1WVifk5rvcFi/y1onBRxjaFMjZeR9mFpqu2gmAZ9xUB9/IEanEP/vBtGeGganC/GO1fmufg==",
1874 "license": "MIT"2002 "license": "MIT"
1875 },2003 },
1876 "node_modules/devlop": {2004 "node_modules/devlop": {
...@@ -1890,7 +2018,8 @@...@@ -1890,7 +2018,8 @@
1890 "version": "0.0.1508733",2018 "version": "0.0.1508733",
1891 "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1508733.tgz",2019 "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1508733.tgz",
1892 "integrity": "sha512-QJ1R5gtck6nDcdM+nlsaJXcelPEI7ZxSMw1ujHpO1c4+9l+Nue5qlebi9xO1Z2MGr92bFOQTW7/rrheh5hHxDg==",2020 "integrity": "sha512-QJ1R5gtck6nDcdM+nlsaJXcelPEI7ZxSMw1ujHpO1c4+9l+Nue5qlebi9xO1Z2MGr92bFOQTW7/rrheh5hHxDg==",
1893 "license": "BSD-3-Clause"2021 "license": "BSD-3-Clause",
2022 "peer": true
1894 },2023 },
1895 "node_modules/electron-to-chromium": {2024 "node_modules/electron-to-chromium": {
1896 "version": "1.5.229",2025 "version": "1.5.229",
...@@ -2392,10 +2521,11 @@...@@ -2392,10 +2521,11 @@
2392 "license": "Apache-2.0"2521 "license": "Apache-2.0"
2393 },2522 },
2394 "node_modules/hono": {2523 "node_modules/hono": {
2395 "version": "4.10.3",2524 "version": "4.11.7",
2396 "resolved": "https://registry.npmjs.org/hono/-/hono-4.10.3.tgz",2525 "resolved": "https://registry.npmjs.org/hono/-/hono-4.11.7.tgz",
2397 "integrity": "sha512-2LOYWUbnhdxdL8MNbNg9XZig6k+cZXm5IjHn2Aviv7honhBMOHb+jxrKIeJRZJRmn+htUCKhaicxwXuUDlchRA==",2526 "integrity": "sha512-l7qMiNee7t82bH3SeyUCt9UF15EVmaBvsppY2zQtrbIhl/yzBTny+YUxsVjSjQ6gaqaeVtZmGocom8TzBlA4Yw==",
2398 "license": "MIT",2527 "license": "MIT",
2528 "peer": true,
2399 "engines": {2529 "engines": {
2400 "node": ">=16.9.0"2530 "node": ">=16.9.0"
2401 }2531 }
...@@ -2581,9 +2711,9 @@...@@ -2581,9 +2711,9 @@
2581 "license": "MIT"2711 "license": "MIT"
2582 },2712 },
2583 "node_modules/js-yaml": {2713 "node_modules/js-yaml": {
2584 "version": "4.1.0",2714 "version": "4.1.1",
2585 "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz",2715 "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
2586 "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==",2716 "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
2587 "license": "MIT",2717 "license": "MIT",
2588 "dependencies": {2718 "dependencies": {
2589 "argparse": "^2.0.1"2719 "argparse": "^2.0.1"
...@@ -2847,9 +2977,9 @@...@@ -2847,9 +2977,9 @@
2847 }2977 }
2848 },2978 },
2849 "node_modules/mdast-util-to-hast": {2979 "node_modules/mdast-util-to-hast": {
2850 "version": "13.2.0",2980 "version": "13.2.1",
2851 "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.0.tgz",2981 "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz",
2852 "integrity": "sha512-QGYKEuUsYT9ykKBCMOEDLsU5JRObWQusAolFMeko/tYPufNkRffBAQjIE+99jbA87xv6FgmjLtwjh9wBWajwAA==",2982 "integrity": "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==",
2853 "license": "MIT",2983 "license": "MIT",
2854 "dependencies": {2984 "dependencies": {
2855 "@types/hast": "^3.0.0",2985 "@types/hast": "^3.0.0",
...@@ -4225,6 +4355,7 @@...@@ -4225,6 +4355,7 @@
4225 "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",4355 "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
4226 "devOptional": true,4356 "devOptional": true,
4227 "license": "Apache-2.0",4357 "license": "Apache-2.0",
4358 "peer": true,
4228 "bin": {4359 "bin": {
4229 "tsc": "bin/tsc",4360 "tsc": "bin/tsc",
4230 "tsserver": "bin/tsserver"4361 "tsserver": "bin/tsserver"
src/file-viewer/backend.tsx+19-5
...@@ -13,11 +13,18 @@ app.get("/file/*", async (c, next) => {...@@ -13,11 +13,18 @@ app.get("/file/*", async (c, next) => {
13 const ua = c.req.header("User-Agent")?.toLowerCase() ?? "";13 const ua = c.req.header("User-Agent")?.toLowerCase() ?? "";
14 const lofi = ua.includes("msie") || false;14 const lofi = ua.includes("msie") || false;
1515
16 let rawFilePath = c.req.path.slice(5) || "/";
17
16 // Some robots ignore 'robots.txt' which violates the license agreement.18 // Some robots ignore 'robots.txt' which violates the license agreement.
17 if (19 if (
18 (!ua.startsWith("mozilla/")) ||20 !rawFilePath.endsWith("thumbnail.jpeg") && (
19 ua.includes("headless") ||21 (!ua.startsWith("mozilla/") &&
20 ua.includes("discord")22 // these two are for AirPlay
23 !ua.includes("airplay") &&
24 !ua.includes("applecoremedia")) ||
25 ua.includes("headless") ||
26 ua.includes("discord")
27 )
21 ) {28 ) {
22 return c.body(29 return c.body(
23 "Forbidden\n\nYour user agent, " + JSON.stringify(ua) +30 "Forbidden\n\nYour user agent, " + JSON.stringify(ua) +
...@@ -28,8 +35,6 @@ app.get("/file/*", async (c, next) => {...@@ -28,8 +35,6 @@ app.get("/file/*", async (c, next) => {
28 );35 );
29 }36 }
3037
31 let rawFilePath = c.req.path.slice(5) || "/";
32
33 let derivedKey = "";38 let derivedKey = "";
34 if (rawFilePath.includes(":/")) {39 if (rawFilePath.includes(":/")) {
35 [rawFilePath, derivedKey] = rawFilePath.split(":/") as [string, string];40 [rawFilePath, derivedKey] = rawFilePath.split(":/") as [string, string];
...@@ -102,6 +107,14 @@ app.get("/file/*", async (c, next) => {...@@ -102,6 +107,14 @@ app.get("/file/*", async (c, next) => {
102 return next();107 return next();
103 }108 }
104 }109 }
110
111 // If an asset is too big, just redirect to the direct server URL.
112 if (size >= 1_000_000_000 && !derivedKey) {
113 return c.redirect(
114 `https://file.paperclover.net/public${escapeUri(file.path)}`,
115 );
116 }
117
105 const etag = `"${hash}"`;118 const etag = `"${hash}"`;
106119
107 const headers = new Headers({120 const headers = new Headers({
...@@ -313,3 +326,4 @@ import { Speedbump } from "@/file-viewer/cotyledon.tsx";...@@ -313,3 +326,4 @@ import { Speedbump } from "@/file-viewer/cotyledon.tsx";
313import * as cache from "@/file-viewer/cache.ts";326import * as cache from "@/file-viewer/cache.ts";
314import * as derived from "@/file-viewer/models/derived.ts";327import * as derived from "@/file-viewer/models/derived.ts";
315import { UNWRAP } from "lib/assert.ts";328import { UNWRAP } from "lib/assert.ts";
329import { escapeUri } from "./format.ts";
src/file-viewer/cache.ts+34-10
...@@ -145,6 +145,9 @@ export async function read(...@@ -145,6 +145,9 @@ export async function read(
145145
146 if (localOnly) return null;146 if (localOnly) return null;
147147
148 const suffix = derived ? ":/" + derived.split("/").slice(1).join("/") : "";
149 const url = `${sotUrl}/file${file.path}${suffix}`;
150
148 // Disk cache151 // Disk cache
149 const diskPath = diskCacheRoot.join(hash);152 const diskPath = diskCacheRoot.join(hash);
150 let diskFile = diskCache.get(diskPath);153 let diskFile = diskCache.get(diskPath);
...@@ -158,6 +161,17 @@ export async function read(...@@ -158,6 +161,17 @@ export async function read(
158 });161 });
159 if (diskStream) {162 if (diskStream) {
160 const isPending = diskFile.written.value !== diskFile.size;163 const isPending = diskFile.written.value !== diskFile.size;
164
165 // TODO: fix this? i'm suspicious
166 if (isPending) {
167 diskStream.close();
168 return {
169 stream: await fetchAndSlice(rel, url, start, end),
170 size,
171 src: "contended",
172 };
173 }
174
161 console.info(`from ${isPending ? "pending " : ""}disk cache: ${rel}`);175 console.info(`from ${isPending ? "pending " : ""}disk cache: ${rel}`);
162 return {176 return {
163 stream: stream.Readable.toWeb(diskStream),177 stream: stream.Readable.toWeb(diskStream),
...@@ -169,15 +183,11 @@ export async function read(...@@ -169,15 +183,11 @@ export async function read(
169 }183 }
170 }184 }
171185
172 // Fetch the source of truth
173 const suffix = derived ? ":/" + derived.split("/").slice(1).join("/") : "";
174 const url = `${sotUrl}/file${file.path}${suffix}`;
175
176 // Do not cache large assets186 // Do not cache large assets
177 if (size > 1_000_000_000) {187 if (size > 1_000_000_000) {
178 console.info(`skipping disk cache due to large asset: ${rel}`);188 console.info(`skipping disk cache due to large asset: ${rel}`);
179 return {189 return {
180 stream: fetchAndSlice(rel, url, start, end),190 stream: await fetchAndSlice(rel, url, start, end),
181 size,191 size,
182 src: "ignored",192 src: "ignored",
183 };193 };
...@@ -186,7 +196,7 @@ export async function read(...@@ -186,7 +196,7 @@ export async function read(
186 if (diskCache.unlockedCapacity < size) {196 if (diskCache.unlockedCapacity < size) {
187 console.info(`skipping disk cache due to contention: ${rel}`);197 console.info(`skipping disk cache due to contention: ${rel}`);
188 return {198 return {
189 stream: fetchAndSlice(rel, url, start, end),199 stream: await fetchAndSlice(rel, url, start, end),
190 size,200 size,
191 src: "contended",201 src: "contended",
192 };202 };
...@@ -196,7 +206,7 @@ export async function read(...@@ -196,7 +206,7 @@ export async function read(
196 if (noWrite) {206 if (noWrite) {
197 console.info(`skipping disk cache due to config: ${rel}`);207 console.info(`skipping disk cache due to config: ${rel}`);
198 return {208 return {
199 stream: fetchAndSlice(rel, url, start, end),209 stream: await fetchAndSlice(rel, url, start, end),
200 size,210 size,
201 src: "ratelimit",211 src: "ratelimit",
202 };212 };
...@@ -207,7 +217,16 @@ export async function read(...@@ -207,7 +217,16 @@ export async function read(
207 diskFile = { size, written: new async.Watch(0), memory: new Map() };217 diskFile = { size, written: new async.Watch(0), memory: new Map() };
208 diskCache.set(diskPath, diskFile);218 diskCache.set(diskPath, diskFile);
209 const lock = diskCache.lock(diskPath);219 const lock = diskCache.lock(diskPath);
210 return fetchAndStore(rel, url, size, diskPath, diskFile, start, end, lock);220 return await fetchAndStore(
221 rel,
222 url,
223 size,
224 diskPath,
225 diskFile,
226 start,
227 end,
228 lock,
229 );
211}230}
212231
213async function fetchAndStore(232async function fetchAndStore(
...@@ -218,7 +237,7 @@ async function fetchAndStore(...@@ -218,7 +237,7 @@ async function fetchAndStore(
218 diskFile: DiskFile,237 diskFile: DiskFile,
219 start: number,238 start: number,
220 end: number,239 end: number,
221 lock: Lock,240 lock: ts.Dispose,
222) {241) {
223 try {242 try {
224 let response = await fetch(rel, url);243 let response = await fetch(rel, url);
...@@ -236,7 +255,11 @@ async function fetchAndStore(...@@ -236,7 +255,11 @@ async function fetchAndStore(
236 diskFile.written.value = Infinity;255 diskFile.written.value = Infinity;
237 });256 });
238257
239 return { stream: sliceStream(response, start, end), size, src: "miss" };258 return {
259 stream: sliceStream(response, start, end),
260 size,
261 src: "miss",
262 } as const;
240 } catch (e) {263 } catch (e) {
241 lock();264 lock();
242 throw e;265 throw e;
...@@ -590,3 +613,4 @@ import type { ReadableStream } from "node:stream/web";...@@ -590,3 +613,4 @@ import type { ReadableStream } from "node:stream/web";
590import { Lru } from "lib/Lru.ts";613import { Lru } from "lib/Lru.ts";
591import { Path } from "#sitegen/path";614import { Path } from "#sitegen/path";
592import { ASSERT, UNWRAP } from "lib/assert.ts";615import { ASSERT, UNWRAP } from "lib/assert.ts";
616import * as ts from "lib/ts.ts";
src/pages/history-of-japan-reanimated.client.ts created+137
...@@ -0,0 +1,137 @@
1// this script is mostly written by ai
2
3const urls: Record<string, string> = {
4 "Kappacap": "https://www.youtube.com/channel/UClywJ-scBa8hcCywQ_7gWbg",
5 "atamai": "https://www.tumblr.com/atamai-m8",
6 "moxley": "https://www.youtube.com/channel/UCNJ6EbYQcnqpB6cQLGYXQow",
7 "TheJustinator": "https://www.youtube.com/channel/UCdy0IJXbdKyTWyKajyEvodw",
8 "chaosyumi": "https://chaosyumi.net",
9 "LunaPogi": "https://www.youtube.com/channel/UC7NHQWu3awWcC0rm8nB5CfQ",
10 "zeccet": "https://www.youtube.com/channel/UChec240BfC1UKX9DvBBIyLQ",
11 "TheRealTinyWorld":
12 "https://www.youtube.com/channel/UCLfv-6_J0WLsA2jAsJxiLOg",
13 "Fred French VA": "https://www.castingcall.club/fredfrenchiii",
14 "rqfirqfo": "https://www.youtube.com/channel/UCqeAGMquQNtrSfd-quZX3KQ",
15 "krispykarim": "https://www.youtube.com/@krispykarim",
16 "SimplyDoodled Studios":
17 "https://www.youtube.com/channel/UCkhbgaqiIkliOKyEORnkhDg",
18 "paper clover": "",
19 "Coffee": "https://www.youtube.com/channel/UCftnPCmG6dfQ50YOMRMtkjg",
20 "harjjw": "https://www.youtube.com/channel/UCPQv3ueB-E771gnwcgALzxw",
21 "yellowmarkers": "https://www.youtube.com/channel/UCcR5G761gkEKvWskjEqWFkQ",
22 "TheNothingGuy07": "https://www.youtube.com/channel/UC8SZGQ32RQxiQeTnQ5hgYAg",
23 "bill wurtz": "https://billwurtz.com",
24};
25
26const animators: [string, number][] = [
27 ["atamai", 0 * 60 + 3.1],
28 ["TheJustinator", 1 * 60 + 2],
29 ["chaosyumi", 1 * 60 + 33],
30 ["TheJustinator", 2 * 60 + 1],
31 ["zeccet", 2 * 60 + 22],
32 ["TheRealTinyWorld", 3 * 60 + 10],
33 ["LunaPogi", 3 * 60 + 35],
34 ["moxley", 3 * 60 + 50],
35 ["rqfirqfo", 4 * 60 + 3],
36 ["krispykarim", 4 * 60 + 35],
37 ["SimplyDoodled Studios", 5 * 60 + 4],
38 ["chaosyumi", 5 * 60 + 33],
39 ["paper clover", 6 * 60 + 4],
40 ["harjjw", 6 * 60 + 37],
41 ["yellowmarkers", 7 * 60 + 3],
42 ["TheNothingGuy07", 7 * 60 + 36],
43 ["LunaPogi", 8 * 60 + 0],
44 ["paper clover", 9 * 60 + 11],
45];
46
47const narrators: [string, number][] = [
48 ["moxley", 0 * 60 + 3.1],
49 ["Fred French VA", 3 * 60 + 10],
50 ["krispykarim", 4 * 60 + 35],
51 ["LunaPogi", 8 * 60 + 0],
52];
53
54const musicians: [string, number][] = [
55 ["moxley", 0 * 60 + 3.1],
56 ["TheJustinator", 3 * 60 + 10],
57 ["LunaPogi", 6 * 60 + 4],
58 ["bill wurtz", 9 * 60 + 11],
59];
60
61// random colors for each creator
62const creatorHues: Record<string, number> = {};
63for (const name of Object.keys(urls)) {
64 let hash = 0;
65 for (let i = 0; i < name.length; i++) {
66 hash = ((hash << 5) - hash) + name.charCodeAt(i);
67 hash |= 0;
68 }
69 creatorHues[name] = hash % 360;
70}
71
72function findCurrent(list: [string, number][], time: number): string | null {
73 let current: string | null = null;
74 for (const [name, start] of list) {
75 if (time >= start) {
76 current = name;
77 } else {
78 break;
79 }
80 }
81 return current;
82}
83
84function createCreditElement(name: string, role: string): string {
85 const hue = creatorHues[name] ?? 0;
86 const url = urls[name];
87 const style =
88 `background: hsl(${hue}, 60%, 25%); color: hsl(${hue}, 80%, 85%);`;
89
90 if (url) {
91 return `<a class="custom" href="${url}" target="_blank" style="${style}">${name} <small>(${role})</small></a>`;
92 }
93 return `<a style="${style}">${name} <small>(${role})</small></a>`;
94}
95
96const creditsBox = document.getElementById("live-credits")!;
97
98let lastCreditsKey = "";
99
100function updateCredits(video) {
101 const time = video.currentTime;
102
103 const animator = time > 9 * 60 + 32 ? null : findCurrent(animators, time);
104 const narrator = time > 9 * 60 + 11 ? null : findCurrent(narrators, time);
105 const musician = time > 9 * 60 + 32 ? null : findCurrent(musicians, time);
106
107 // group roles by person
108 const rolesByPerson: Record<string, string[]> = {};
109 if (animator) (rolesByPerson[animator] ??= []).push("animation");
110 if (narrator) (rolesByPerson[narrator] ??= []).push("narration");
111 if (musician) (rolesByPerson[musician] ??= []).push("music");
112
113 const creditsKey = JSON.stringify(rolesByPerson);
114 if (creditsKey === lastCreditsKey) return;
115 lastCreditsKey = creditsKey;
116
117 const html = Object.entries(rolesByPerson)
118 .map(([name, roles]) => createCreditElement(name, roles.join(", ")))
119 .join(" ");
120
121 creditsBox.innerHTML = html
122 ? "<b style='flex:none;padding:0 8px'>credits: </b>" + html
123 : "";
124}
125
126globalThis.onCloverVideoInit = (id: string, video: HTMLVideoElement) => {
127 console.log(1, id, video);
128 if (id === "main-player") {
129 updateCredits(video);
130 video.addEventListener("timeupdate", () => {
131 updateCredits(video);
132 });
133 video.addEventListener("seeked", () => {
134 updateCredits(video);
135 });
136 }
137};
src/pages/history-of-japan-reanimated.css created+38
...@@ -0,0 +1,38 @@
1#live-credits {
2 background: #602945;
3 color: white;
4 height: 30px;
5 display: flex;
6 margin-top: -16px;
7 align-items: center;
8}
9#live-credits a {
10 flex: auto;
11 padding: 0 8px;
12 height: 100%;
13 display: flex;
14 align-items: center;
15 gap: 5px;
16}
17
18@media (max-width: 768px) {
19 #live-credits {
20 flex-direction: column;
21 height: unset;
22
23 b {
24 flex: auto;
25 padding: 0 8px;
26 display: flex;
27 align-items: center;
28 gap: 5px;
29 height: 30px;
30 }
31
32 a {
33 height: 30px;
34 width: 100%;
35 justify-content: center;
36 }
37 }
38}
src/pages/history-of-japan-reanimated.marko created+104
...@@ -0,0 +1,104 @@
1import "./history-of-japan-reanimated.css";
2
3client import "./history-of-japan-reanimated.client.ts";
4
5export const meta = {
6 title: "history of japan reanimated",
7 description: "17 artists and musicians come together to remake bill wurtz's legendary \"history of japan\" to celebrate its 10 year anniversary.",
8 embed: {
9 thumbnail: "https://paperclover.net/file/2026/history%20of%20japan%20reanimated/thumbnail.jpeg"
10 },
11 canonical: "/history-of-japan-reanimated",
12 twitter: {
13 image: "https://paperclover.net/file/2026/history%20of%20japan%20reanimated/thumbnail.jpeg"
14 },
15};
16export const theme = {
17 fg: "#fff",
18 bg: "#442233",
19 primary: "#ff8b47"
20};
21
22<main>
23 <h1>history of japan reanimated</h1>
24 <p>
25 me and 16 other artists (music, narration, and animation) have been
26 spending the past couple months on a recreation of bill wurtz's legendary
27 "<a href="https://billwurtz.com/history-of-japan.html">history of japan</a>" to celebrate the 10 year anniversary of his release. i
28 contributed two animation sections. the canonical release is on
29 <a href="https://youtu.be/nqsoti-OuRM">LunaPogi's YouTube page</a>, but i
30 am providing this ad-free video player.
31 </p>
32
33<clover-video
34 id="main-player"
35 file="/2026/history of japan reanimated/history of japan reanimated.mp4"
36 poster="/2026/history of japan reanimated/thumbnail.jpeg"
37>
38 <@header>history of japan reanimated</>
39</>
40
41<div id="live-credits">
42
43
44</div>
45
46 <p>
47 the wonderful souls who worked on the project, in order of appearance.
48 </p>
49 <ul>
50 <li><a href="https://www.youtube.com/channel/UClywJ-scBa8hcCywQ_7gWbg">Kappacap</a> - thumbnail artist</li>
51 <li><a href="https://www.tumblr.com/atamai-m8">atamai</a> - animation <span style="color:#fff9">(0:03-1:02)</></li>
52 <li><a href="https://www.youtube.com/channel/UCNJ6EbYQcnqpB6cQLGYXQow">moxley</a> - music and narration <span style="color:#fff9">(0:03-3:10)</>, animation <span style="color:#fff9">(3:51-4:03)</></li>
53 <li><a href="https://www.youtube.com/channel/UCdy0IJXbdKyTWyKajyEvodw">TheJustinator</a> - animation <span style="color:#fff9">(1:03-1:33, 2:02-2:33)</>, music <span style="color:#fff9">(3:11-6:04)</></li>
54 <li><a href="https://chaosyumi.net">chaosyumi</a> - animation <span style="color:#fff9">(1:34-2:01, 5:34-6:05)</></li>
55 <li><a href="https://www.youtube.com/channel/UC7NHQWu3awWcC0rm8nB5CfQ">LunaPogi</a> - animation <span style="color:#fff9">(3:36-3:51, 8:01-9:10)</>, music <span style="color:#fff9">(6:05-9:10)</>, narration <span style="color:#fff9">(8:01-9:10)</></li>
56 <li><a href="https://www.youtube.com/channel/UChec240BfC1UKX9DvBBIyLQ">zeccet (bgongo)</a> - animation <span style="color:#fff9">(2:23-3:10)</></li>
57 <li><a href="https://www.youtube.com/channel/UCLfv-6_J0WLsA2jAsJxiLOg">TheRealTinyWorld</a> - animation <span style="color:#fff9">(3:11-3:35)</></li>
58 <li><a href="https://www.castingcall.club/fredfrenchiii">Fred French VA</a> - narration <span style="color:#fff9">(3:11-4:35)</></li>
59 <li><a href="https://www.youtube.com/channel/UCqeAGMquQNtrSfd-quZX3KQ">rqfirqfo</a> - animation <span style="color:#fff9">(4:04-4:35)</></li>
60 <li><a href="https://www.youtube.com/@krispykarim">krispykarim</a> - animation <span style="color:#fff9">(4:36 - 5:04)</>, narration <span style="color:#fff9">(4:36-8:01)</></li>
61 <li><a href="https://www.youtube.com/channel/UCkhbgaqiIkliOKyEORnkhDg">SimplyDoodled Studios</a> - animation <span style="color:#fff9">(5:05-5:33)</></li>
62 <li><span style="color:#ff7de9">paper clover (me)</span> - animation <span style="color:#fff9">(6:05-6:38)</>, credits animation <span style="color:#fff9">(9:11-9:35)</></li>
63 <li><a href="https://www.youtube.com/channel/UCftnPCmG6dfQ50YOMRMtkjg">Coffee</a> - transcription <span style="color:#fff9">(6:05-9:10)</></li>
64 <li><a href="https://www.youtube.com/channel/UCPQv3ueB-E771gnwcgALzxw">harjjw</a> - animation <span style="color:#fff9">(6:38-7:03)</></li>
65 <li><a href="https://www.youtube.com/channel/UCcR5G761gkEKvWskjEqWFkQ">yellowmarkers</a> - animation <span style="color:#fff9">(7:04-7:36)</></li>
66 <li><a href="https://www.youtube.com/channel/UC8SZGQ32RQxiQeTnQ5hgYAg">TheNothingGuy07</a> - animation <span style="color:#fff9">(7:37-7:59)</></li>
67 </ul>
68
69 <p>
70 the music during the credits is an instrumental by bill wurtz: <a href="https://billwurtz.com/hey-mom-i-beat-the-scale.mp3">hey mom i beat the scale!</a>
71 </p>
72
73 <h2>what's next</h2>
74 <p>
75 from the success of this project, a much more ambitous project is being
76 planned: the reanimation for 'history of the entire world, i guess'. while
77 some planning between co-hosts is in progress, i am putting out <a href="https://docs.google.com/forms/d/e/1FAIpQLSdMdwotGKmrDYT8stYPm5uoOlmqZPLApYp8MuABDmcAwW6Fig/viewform">an
78 interest form</a> if this project is something you're interested in working
79 on. by submitting the form, i'll be able to reach out when it's actually time to start cooking.
80 </p>
81
82 <h2>behind the scenes</h2>
83 <p>
84 here is a 55 minute dive into my process, as well as nearly every little trick i used on this project. additionally, for one of the first times from me, a free download for <a href="/file/2026/history%20of%20japan%20reanimated/project%20files/history-of-japan-reanimated.zip?dl">all of the project files</a>.
85 </p>
86 <clover-video
87 file="/2026/history of japan reanimated/behind-the-scenes.mp4"
88 >
89 <@header>behind the scenes session with clover</>
90 </>
91
92
93 <h2>more links</h2>
94 <p>
95 <strong>more</strong>: <br />
96 <a href="/file/2026/history%20of%20japan%20reanimated/fragments">fragments</a>
97 <br />
98 <a href="/q+a">paper clover q&a page</a><br/>
99 <a href="/subscribe">paper clover's mailing list</a>
100 </p>
101
102 <h2>mentions on the q&a</h2>
103 <questions-embed="history-of-japan-reanimated" />
104</main>
src/pages/index.marko+1
...@@ -21,6 +21,7 @@ export const meta = {...@@ -21,6 +21,7 @@ export const meta = {
21 this site contains my art.21 this site contains my art.
22 </p>22 </p>
23 <h2>posts</h2>23 <h2>posts</h2>
24 <p>video collab: <a href="/history-of-japan-reanimated">history of japan reanimated</a> (2026-02-02)</p>
24 <p style='display:flex'><span style='flex:0 0 max-content'>blog:&nbsp;</span><span style='flex: 1 0 0px'><a href="/blog/webdev/one-year-next-app-router">One Year with Next.js App Router — why we're moving on</a> (2025-10-21)</span></p>25 <p style='display:flex'><span style='flex:0 0 max-content'>blog:&nbsp;</span><span style='flex: 1 0 0px'><a href="/blog/webdev/one-year-next-app-router">One Year with Next.js App Router — why we're moving on</a> (2025-10-21)</span></p>
25 <p>song: <a href="/in-the-summer">in the summer</a> (2025-08-08)</p>26 <p>song: <a href="/in-the-summer">in the summer</a> (2025-08-08)</p>
26 <p>song: <a href="/waterfalls">waterfalls</a> (2025-01-01)</p>27 <p>song: <a href="/waterfalls">waterfalls</a> (2025-01-01)</p>
src/tags/clover-video.client.ts+140-38
...@@ -2,6 +2,9 @@...@@ -2,6 +2,9 @@
22
3// Polyfill for old browsers.3// Polyfill for old browsers.
4type PlaybackMode = "av1" | "hls-polyfill" | "hls-native" | "none";4type PlaybackMode = "av1" | "hls-polyfill" | "hls-native" | "none";
5const forceMode = location.search.match(/(?:\?|&)force-player=(.*)/)?.[1] as
6 | PlaybackMode
7 | undefined;
5let mode: Promise<PlaybackMode> | null = null;8let mode: Promise<PlaybackMode> | null = null;
6let hls: Promise<typeof import("hls.js")> | null = null;9let hls: Promise<typeof import("hls.js")> | null = null;
710
...@@ -13,6 +16,7 @@ Promise.allSettled ??= <T>(promises: Iterable<T | PromiseLike<T>>) =>...@@ -13,6 +16,7 @@ Promise.allSettled ??= <T>(promises: Iterable<T | PromiseLike<T>>) =>
13 (reason) => ({ status: "rejected" as const, reason }),16 (reason) => ({ status: "rejected" as const, reason }),
14 )));17 )));
1518
19declare function hydrateVideo(container: HTMLElement): void;
16globalThis.hydrateVideo = function hydrateVideo(container) {20globalThis.hydrateVideo = function hydrateVideo(container) {
17 mode ??= decidePlaybackMode();21 mode ??= decidePlaybackMode();
18 mode.then((mode) => hydrateVideoInner(container, mode));22 mode.then((mode) => hydrateVideoInner(container, mode));
...@@ -23,9 +27,21 @@ Array.from(...@@ -23,9 +27,21 @@ Array.from(
23 (container) => hydrateVideo(container),27 (container) => hydrateVideo(container),
24);28);
2529
30// TODO: be less stupid about this
31const onCloverVideoInit = window.onCloverVideoInit;
32
26function decidePlaybackMode(): Promise<PlaybackMode> {33function decidePlaybackMode(): Promise<PlaybackMode> {
34 if (forceMode) {
35 if (forceMode === "hls-polyfill") {
36 // @ts-expect-error
37 return (hls ??= import("/js/scripts/vendor/hls.js"))
38 .then(() => forceMode);
39 }
40 return Promise.resolve(forceMode);
41 }
42
27 return shouldPlayAv1().catch(() => ({ hardware: false, supported: false }))43 return shouldPlayAv1().catch(() => ({ hardware: false, supported: false }))
28 .then((av1): Awaitable<PlaybackMode> => {44 .then((av1): PlaybackMode | Promise<PlaybackMode> => {
29 // Native AV1 with DASH Polyfill45 // Native AV1 with DASH Polyfill
30 // (Chrome v70, Safari v17 on M3/A17 chip, Firefox v67)46 // (Chrome v70, Safari v17 on M3/A17 chip, Firefox v67)
31 if (av1.hardware) return "av1";47 if (av1.hardware) return "av1";
...@@ -43,6 +59,7 @@ function decidePlaybackMode(): Promise<PlaybackMode> {...@@ -43,6 +59,7 @@ function decidePlaybackMode(): Promise<PlaybackMode> {
43 return "hls-native";59 return "hls-native";
44 }60 }
4561
62 // @ts-expect-error
46 return (hls ??= import("/js/scripts/vendor/hls.js"))63 return (hls ??= import("/js/scripts/vendor/hls.js"))
47 .then((hls): PlaybackMode => {64 .then((hls): PlaybackMode => {
48 // Polyfill HLS (Chrome 23, Firefox 42, IE11 on Win8)65 // Polyfill HLS (Chrome 23, Firefox 42, IE11 on Win8)
...@@ -59,80 +76,165 @@ function decidePlaybackMode(): Promise<PlaybackMode> {...@@ -59,80 +76,165 @@ function decidePlaybackMode(): Promise<PlaybackMode> {
59 });76 });
60}77}
6178
62declare global {
63 function hydrateVideo(container: HTMLElement): void;
64}
65
66function hydrateVideoInner(79function hydrateVideoInner(
67 container: HTMLElement,80 container: HTMLElement,
68 mode: PlaybackMode,81 mode: PlaybackMode,
69) {82) {
70 const video = container instanceof HTMLVideoElement83 let video = container instanceof HTMLVideoElement
71 ? container84 ? container
72 : container.querySelector("video")!;85 : container.querySelector("video")!;
86 const id = video.id;
73 if (video.hasAttribute("controls")) return;87 if (video.hasAttribute("controls")) return;
74 let src = video.getAttribute("data-src");88 let src = video.getAttribute("data-src");
75 if (!src) return;89 if (!src) return;
76 src = `/file${src}`;90 src = `/file${src}`;
91 src = new URL(src, location.origin).href;
7792
78 const dashFile = `${src}:/dash.mpd`;93 const dashFile = `${src}:/dash.mpd`;
79 const hlsFile = `${src}:/master.m3u8`;94 const hlsFile = `${src}:/master.m3u8`;
8095
96 if (forceMode) {
97 const figcaption = container.querySelector("figcaption");
98 if (figcaption) {
99 figcaption.innerHTML += "&nbsp;(force player backend: " + forceMode + ")";
100 }
101 }
102
81 if (mode === "av1") {103 if (mode === "av1") {
104 // @ts-expect-error
82 import("/js/scripts/vendor/dash.js").then((dashjs) => {105 import("/js/scripts/vendor/dash.js").then((dashjs) => {
83 const player = dashjs.MediaPlayer().create();
84 // This proxying is for ManagedMediaSource, exposing the HLS stream for AirPlay
85 let mainSource: HTMLSourceElement | null = null;106 let mainSource: HTMLSourceElement | null = null;
86 Object.defineProperty(video, "src", {107 let mediaSource: MediaSource | null = null, objectUrl = "";
87 get: () => false,108 const hasThumbnail = video.hasAttribute("poster");
88 set: (v) => {109
89 if (!mainSource) {110 const player = dashjs.MediaPlayer().create();
90 mainSource = document.createElement("source");111
91 mainSource.src = v;112 // the world is so simple when there is no thumbnail because we are not
92 video.appendChild(mainSource);113 // working around a billion sharp edges.
93 } else {114 if (!hasThumbnail) {
94 mainSource.src = v;115 enableSafariAirplay();
95 }116 player.initialize(video, dashFile, false);
96 },117 video.controls = true;
118 onCloverVideoInit?.(id, video);
119 return;
120 }
121
122 // With Dash.js, we get pranked. No matter if preloading is on or off,
123 // the first frame is always preloaded and will override the poster.
124 // To avoid this, the viewer is attached later. However, on Chrome the
125 // video is still not playable until metadata has loaded in, so a fake
126 // MediaSource with one invisible frame is used.
127 player.initialize(null, dashFile, false);
128 player.updateSettings({
129 streaming: { cacheInitSegments: true },
97 });130 });
98 Object.defineProperty(video, "disableRemotePlayback", {131 player.preload();
99 get: () => false,132
100 set: () => {133 if (typeof MediaSource !== "undefined") {
101 const source = document.createElement("source");134 const duration = new Promise<number>((resolve) => {
102 source.src = hlsFile;135 player.on(dashjs.MediaPlayer.events.MANIFEST_LOADED, (e) => {
103 video.appendChild(source);136 const duration = e.data.mediaPresentationDuration;
104 },137 resolve(duration); // seconds
138 });
139 });
140 mediaSource = new MediaSource();
141 mediaSource.addEventListener("sourceopen", () => {
142 duration.then((duration) => {
143 mediaSource = mediaSource!;
144 mediaSource.duration = duration;
145 const sb = mediaSource.addSourceBuffer('video/webm; codecs="vp8"');
146 const oneFrame = Uint8Array.from(
147 atob(
148 "GkXfo59ChoEBQveBAULygQRC84EIQoKEd2VibUKHgQJChYECGFOAZwEAAAAAAAITEU2bdLpNu4tTq4QVSalmU6yBoU27i1OrhBZUrmtTrIHWTbuMU6uEElTDZ1OsggEjTbuMU6uEHFO7a1OsggH97AEAAAAAAABZ" +
149 "A".repeat(119) +
150 "VSalmsCrXsYMPQkBNgIxMYXZmNjIuMy4xMDBXQYxMYXZmNjIuMy4xMDBEiYhAXgAAAAAAABZUrmvIrgEAAAAAAAA/14EBc8WIDV7YG1KxA/CcgQAitZyDdW5kiIEAhoVWX1ZQOIOBASPjg4QCYloA4JCwgQG6gQGagQJVsIRVuYEBElTDZ/tzc59jwIBnyJlFo4dFTkNPREVSRIeMTGF2ZjYyLjMuMTAwc3PWY8CLY8WIDV7YG1KxA/BnyKFFo4dFTkNPREVSRIeUTGF2YzYyLjExLjEwMCBsaWJ2cHhnyKFFo4hEVVJBVElPTkSHkzAwOjAwOjAwLjEyMDAwMDAwMAAfQ7Z11eeBAKOigQAAgBACAJ0BKgEAAQALxwiFhYiFhIg/ggAMDWAA/ua1AKOVgQAoALEBAC8R/AAYABhYL/QAJAAAo5WBAFAAsQEALxH8ABgAGFgv9AAkAAAcU7trkbuPs4EAt4r3gQHxggGj8IED",
151 ),
152 (x) => x.charCodeAt(0),
153 );
154 sb.appendBuffer(oneFrame);
155 });
156 });
157 objectUrl = URL.createObjectURL(mediaSource);
158
159 mainSource = document.createElement("source");
160 mainSource.src = objectUrl;
161 video.appendChild(mainSource);
162 }
163 video.addEventListener("play", function play() {
164 video.removeEventListener("play", play);
165
166 // Revoke Fake MediaSource
167 if (objectUrl) {
168 mainSource?.remove();
169 mainSource = null;
170 mediaSource?.endOfStream();
171 mediaSource = null;
172 URL.revokeObjectURL(objectUrl);
173
174 const newVideo = document.createElement("video");
175 newVideo.poster = video.poster;
176 newVideo.controls = true;
177 video.parentElement!.insertBefore(newVideo, video);
178 video.remove();
179 video = newVideo;
180 }
181
182 enableSafariAirplay();
183 player.attachView(video);
184 if (objectUrl) video.play();
185 onCloverVideoInit?.(id, video);
105 });186 });
106 player.initialize(
107 video,
108 dashFile,
109 false, // autoplay
110 0, // start
111 );
112 video.controls = true;187 video.controls = true;
188 onCloverVideoInit?.(id, video);
189
190 // This proxying is for ManagedMediaSource, exposing the HLS stream for AirPlay
191 function enableSafariAirplay() {
192 Object.defineProperty(video, "src", {
193 get: () => false,
194 set: (v) => {
195 if (!mainSource) {
196 mainSource = document.createElement("source");
197 mainSource.src = v;
198 video.appendChild(mainSource);
199 } else {
200 mainSource.src = v;
201 }
202 },
203 });
204 Object.defineProperty(video, "disableRemotePlayback", {
205 get: () => false,
206 set: () => {
207 const source = document.createElement("source");
208 source.src = hlsFile;
209 video.appendChild(source);
210 },
211 });
212 }
113 });213 });
114 } else if (mode === "hls-native") {214 } else if (mode === "hls-native") {
115 const hlsSource = document.createElement("source");215 const hlsSource = document.createElement("source");
116 hlsSource.src = `${src}:/hls.m3u8`;216 hlsSource.src = hlsFile;
117 video.appendChild(hlsSource);217 video.appendChild(hlsSource);
118 video.controls = true;218 video.controls = true;
219 onCloverVideoInit?.(id, video);
119 } else if (mode === "hls-polyfill") {220 } else if (mode === "hls-polyfill") {
120 hls!.then((hls) => {221 hls!.then((hls) => {
121 const player = new hls.default();222 console.log(hls);
223 // @ts-expect-error suspicious
224 const player = new (hls.default ?? hls.Hls)();
122 player.loadSource(hlsFile);225 player.loadSource(hlsFile);
123 player.attachMedia(video);226 player.attachMedia(video);
124 video.controls = true;227 video.controls = true;
228 onCloverVideoInit?.(id, video);
125 });229 });
126 } else {230 } else {
127 video.src = src;231 video.src = src;
128 video.controls = true;232 video.controls = true;
233 onCloverVideoInit?.(id, video);
129 }234 }
130}235}
131236
132declare global {237declare var ManagedMediaSource: typeof MediaSource | null;
133 var ManagedMediaSource: typeof MediaSource | null;
134}
135
136function shouldPlayAv1(): Promise<{ hardware: boolean; supported: boolean }> {238function shouldPlayAv1(): Promise<{ hardware: boolean; supported: boolean }> {
137 const MediaSource = globalThis.ManagedMediaSource ?? globalThis.MediaSource;239 const MediaSource = globalThis.ManagedMediaSource ?? globalThis.MediaSource;
138 if (!MediaSource.isTypeSupported('audio/webm;codecs="opus"')) {240 if (!MediaSource.isTypeSupported('audio/webm;codecs="opus"')) {
src/tags/clover-video.css+1
...@@ -18,6 +18,7 @@...@@ -18,6 +18,7 @@
18}18}
19.clover-video video {19.clover-video video {
20 width: 100%;20 width: 100%;
21 height: 100%;
21}22}
22.clover-video h2 {23.clover-video h2 {
23 margin-top: 0;24 margin-top: 0;
src/tags/clover-video.marko+3-1
...@@ -1,6 +1,7 @@...@@ -1,6 +1,7 @@
1import "./clover-video.css";1import "./clover-video.css";
22
3export interface Input {3export interface Input {
4 id?: string;
4 file: MediaFile | string;5 file: MediaFile | string;
5 poster?: MediaFile | string;6 poster?: MediaFile | string;
6 header?: Marko.AttrTag<{ content: Marko.Body }>;7 header?: Marko.AttrTag<{ content: Marko.Body }>;
...@@ -8,7 +9,7 @@ export interface Input {...@@ -8,7 +9,7 @@ export interface Input {
8 noFigure?: boolean9 noFigure?: boolean
9}10}
1011
11<const/{ header, minimal, noFigure } = input />12<const/{ id, header, minimal, noFigure } = input />
12<const/file = typeof UNWRAP(input.file) === 'object'13<const/file = typeof UNWRAP(input.file) === 'object'
13 ? input.file14 ? input.file
14 : UNWRAP(MediaFile.getByPath(input.file), `No file ${JSON.stringify(input.file)}`) />15 : UNWRAP(MediaFile.getByPath(input.file), `No file ${JSON.stringify(input.file)}`) />
...@@ -22,6 +23,7 @@ export interface Input {...@@ -22,6 +23,7 @@ export interface Input {
2223
23<define/Inner>24<define/Inner>
24 <video25 <video
26 ...{ id }
25 class={ 'clover-video': noFigure }27 class={ 'clover-video': noFigure }
26 data-src=file.path28 data-src=file.path
27 poster=poster ? `/file${poster.path}` : null29 poster=poster ? `/file${poster.path}` : null
tsconfig.json+1-1
...@@ -18,5 +18,5 @@...@@ -18,5 +18,5 @@
18 "noUncheckedIndexedAccess": true,18 "noUncheckedIndexedAccess": true,
19 "resolveJsonModule": true19 "resolveJsonModule": true
20 },20 },
21 "include": ["lib/**/*"]21 "include": ["lib/**/*", "src/**/*", "framework/**/*"]
22}22}