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 @@
44 "requires": true,
55 "packages": {
66 "": {
7 "name": "sitegen",
78 "dependencies": {
89 "@hono/node-server": "1.14.3",
910 "@mdx-js/mdx": "3.1.0",
......@@ -58,6 +59,7 @@
5859 "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.4.tgz",
5960 "integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==",
6061 "license": "MIT",
62 "peer": true,
6163 "dependencies": {
6264 "@babel/code-frame": "^7.27.1",
6365 "@babel/generator": "^7.28.3",
......@@ -1168,11 +1170,129 @@
11681170 "node": ">=10"
11691171 }
11701172 },
1171 "node_modules/@svta/common-media-library": {
1172 "version": "0.12.4",
1173 "resolved": "https://registry.npmjs.org/@svta/common-media-library/-/common-media-library-0.12.4.tgz",
1174 "integrity": "sha512-9EuOoaNmz7JrfGwjsrD9SxF9otU5TNMnbLu1yU4BeLK0W5cDxVXXR58Z89q9u2AnHjIctscjMTYdlqQ1gojTuw==",
1175 "license": "Apache-2.0"
1173 "node_modules/@svta/cml-608": {
1174 "version": "1.0.1",
1175 "resolved": "https://registry.npmjs.org/@svta/cml-608/-/cml-608-1.0.1.tgz",
1176 "integrity": "sha512-Y/Ier9VPUSOBnf0bJqdDyTlPrt4dDB+jk5mYHa1bnD2kcRl8qn7KkW3PRuj4w1aVN+BS2eHmsLxodt7P2hylUg==",
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 }
11761296 },
11771297 "node_modules/@tootallnate/quickjs-emscripten": {
11781298 "version": "0.23.0",
......@@ -1271,6 +1391,7 @@
12711391 "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
12721392 "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
12731393 "license": "MIT",
1394 "peer": true,
12741395 "bin": {
12751396 "acorn": "bin/acorn"
12761397 },
......@@ -1530,6 +1651,7 @@
15301651 }
15311652 ],
15321653 "license": "MIT",
1654 "peer": true,
15331655 "dependencies": {
15341656 "baseline-browser-mapping": "^2.8.9",
15351657 "caniuse-lite": "^1.0.30001746",
......@@ -1778,12 +1900,18 @@
17781900 "license": "MIT"
17791901 },
17801902 "node_modules/dashjs": {
1781 "version": "5.0.3",
1782 "resolved": "https://registry.npmjs.org/dashjs/-/dashjs-5.0.3.tgz",
1783 "integrity": "sha512-TXndNnCUjFjF2nYBxDVba+hWRpVkadkQ8flLp7kHkem+5+wZTfRShJCnVkPUosmjS0YPE9fVNLbYPJxHBeQZvA==",
1903 "version": "5.1.1",
1904 "resolved": "https://registry.npmjs.org/dashjs/-/dashjs-5.1.1.tgz",
1905 "integrity": "sha512-BzNXlUgzEjhuZ5M5hlSp1qIyQHZ7NpXAR0loP9DAAFVZj/ntL1DHeZ7qp/L3bvI4rq50X5indkAZQ3zEHWJoCA==",
17841906 "license": "BSD-3-Clause",
17851907 "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",
17871915 "bcp-47-match": "^2.0.3",
17881916 "bcp-47-normalize": "^2.3.0",
17891917 "codem-isoboxer": "0.3.10",
......@@ -1868,9 +1996,9 @@
18681996 }
18691997 },
18701998 "node_modules/devalue": {
1871 "version": "5.3.2",
1872 "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.3.2.tgz",
1873 "integrity": "sha512-UDsjUbpQn9kvm68slnrs+mfxwFkIflOhkanmyabZ8zOYk8SMEIbJ3TK+88g70hSIeytu4y18f0z/hYHMTrXIWw==",
1999 "version": "5.6.2",
2000 "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.6.2.tgz",
2001 "integrity": "sha512-nPRkjWzzDQlsejL1WVifk5rvcFi/y1onBRxjaFMjZeR9mFpqu2gmAZ9xUB9/IEanEP/vBtGeGganC/GO1fmufg==",
18742002 "license": "MIT"
18752003 },
18762004 "node_modules/devlop": {
......@@ -1890,7 +2018,8 @@
18902018 "version": "0.0.1508733",
18912019 "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1508733.tgz",
18922020 "integrity": "sha512-QJ1R5gtck6nDcdM+nlsaJXcelPEI7ZxSMw1ujHpO1c4+9l+Nue5qlebi9xO1Z2MGr92bFOQTW7/rrheh5hHxDg==",
1893 "license": "BSD-3-Clause"
2021 "license": "BSD-3-Clause",
2022 "peer": true
18942023 },
18952024 "node_modules/electron-to-chromium": {
18962025 "version": "1.5.229",
......@@ -2392,10 +2521,11 @@
23922521 "license": "Apache-2.0"
23932522 },
23942523 "node_modules/hono": {
2395 "version": "4.10.3",
2396 "resolved": "https://registry.npmjs.org/hono/-/hono-4.10.3.tgz",
2397 "integrity": "sha512-2LOYWUbnhdxdL8MNbNg9XZig6k+cZXm5IjHn2Aviv7honhBMOHb+jxrKIeJRZJRmn+htUCKhaicxwXuUDlchRA==",
2524 "version": "4.11.7",
2525 "resolved": "https://registry.npmjs.org/hono/-/hono-4.11.7.tgz",
2526 "integrity": "sha512-l7qMiNee7t82bH3SeyUCt9UF15EVmaBvsppY2zQtrbIhl/yzBTny+YUxsVjSjQ6gaqaeVtZmGocom8TzBlA4Yw==",
23982527 "license": "MIT",
2528 "peer": true,
23992529 "engines": {
24002530 "node": ">=16.9.0"
24012531 }
......@@ -2581,9 +2711,9 @@
25812711 "license": "MIT"
25822712 },
25832713 "node_modules/js-yaml": {
2584 "version": "4.1.0",
2585 "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz",
2586 "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==",
2714 "version": "4.1.1",
2715 "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
2716 "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
25872717 "license": "MIT",
25882718 "dependencies": {
25892719 "argparse": "^2.0.1"
......@@ -2847,9 +2977,9 @@
28472977 }
28482978 },
28492979 "node_modules/mdast-util-to-hast": {
2850 "version": "13.2.0",
2851 "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.0.tgz",
2852 "integrity": "sha512-QGYKEuUsYT9ykKBCMOEDLsU5JRObWQusAolFMeko/tYPufNkRffBAQjIE+99jbA87xv6FgmjLtwjh9wBWajwAA==",
2980 "version": "13.2.1",
2981 "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz",
2982 "integrity": "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==",
28532983 "license": "MIT",
28542984 "dependencies": {
28552985 "@types/hast": "^3.0.0",
......@@ -4225,6 +4355,7 @@
42254355 "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
42264356 "devOptional": true,
42274357 "license": "Apache-2.0",
4358 "peer": true,
42284359 "bin": {
42294360 "tsc": "bin/tsc",
42304361 "tsserver": "bin/tsserver"
src/file-viewer/backend.tsx+19-5
......@@ -13,11 +13,18 @@ app.get("/file/*", async (c, next) => {
1313 const ua = c.req.header("User-Agent")?.toLowerCase() ?? "";
1414 const lofi = ua.includes("msie") || false;
1515
16 let rawFilePath = c.req.path.slice(5) || "/";
17
1618 // Some robots ignore 'robots.txt' which violates the license agreement.
1719 if (
18 (!ua.startsWith("mozilla/")) ||
19 ua.includes("headless") ||
20 ua.includes("discord")
20 !rawFilePath.endsWith("thumbnail.jpeg") && (
21 (!ua.startsWith("mozilla/") &&
22 // these two are for AirPlay
23 !ua.includes("airplay") &&
24 !ua.includes("applecoremedia")) ||
25 ua.includes("headless") ||
26 ua.includes("discord")
27 )
2128 ) {
2229 return c.body(
2330 "Forbidden\n\nYour user agent, " + JSON.stringify(ua) +
......@@ -28,8 +35,6 @@ app.get("/file/*", async (c, next) => {
2835 );
2936 }
3037
31 let rawFilePath = c.req.path.slice(5) || "/";
32
3338 let derivedKey = "";
3439 if (rawFilePath.includes(":/")) {
3540 [rawFilePath, derivedKey] = rawFilePath.split(":/") as [string, string];
......@@ -102,6 +107,14 @@ app.get("/file/*", async (c, next) => {
102107 return next();
103108 }
104109 }
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
105118 const etag = `"${hash}"`;
106119
107120 const headers = new Headers({
......@@ -313,3 +326,4 @@ import { Speedbump } from "@/file-viewer/cotyledon.tsx";
313326import * as cache from "@/file-viewer/cache.ts";
314327import * as derived from "@/file-viewer/models/derived.ts";
315328import { 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(
145145
146146 if (localOnly) return null;
147147
148 const suffix = derived ? ":/" + derived.split("/").slice(1).join("/") : "";
149 const url = `${sotUrl}/file${file.path}${suffix}`;
150
148151 // Disk cache
149152 const diskPath = diskCacheRoot.join(hash);
150153 let diskFile = diskCache.get(diskPath);
......@@ -158,6 +161,17 @@ export async function read(
158161 });
159162 if (diskStream) {
160163 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
161175 console.info(`from ${isPending ? "pending " : ""}disk cache: ${rel}`);
162176 return {
163177 stream: stream.Readable.toWeb(diskStream),
......@@ -169,15 +183,11 @@ export async function read(
169183 }
170184 }
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
176186 // Do not cache large assets
177187 if (size > 1_000_000_000) {
178188 console.info(`skipping disk cache due to large asset: ${rel}`);
179189 return {
180 stream: fetchAndSlice(rel, url, start, end),
190 stream: await fetchAndSlice(rel, url, start, end),
181191 size,
182192 src: "ignored",
183193 };
......@@ -186,7 +196,7 @@ export async function read(
186196 if (diskCache.unlockedCapacity < size) {
187197 console.info(`skipping disk cache due to contention: ${rel}`);
188198 return {
189 stream: fetchAndSlice(rel, url, start, end),
199 stream: await fetchAndSlice(rel, url, start, end),
190200 size,
191201 src: "contended",
192202 };
......@@ -196,7 +206,7 @@ export async function read(
196206 if (noWrite) {
197207 console.info(`skipping disk cache due to config: ${rel}`);
198208 return {
199 stream: fetchAndSlice(rel, url, start, end),
209 stream: await fetchAndSlice(rel, url, start, end),
200210 size,
201211 src: "ratelimit",
202212 };
......@@ -207,7 +217,16 @@ export async function read(
207217 diskFile = { size, written: new async.Watch(0), memory: new Map() };
208218 diskCache.set(diskPath, diskFile);
209219 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 );
211230}
212231
213232async function fetchAndStore(
......@@ -218,7 +237,7 @@ async function fetchAndStore(
218237 diskFile: DiskFile,
219238 start: number,
220239 end: number,
221 lock: Lock,
240 lock: ts.Dispose,
222241) {
223242 try {
224243 let response = await fetch(rel, url);
......@@ -236,7 +255,11 @@ async function fetchAndStore(
236255 diskFile.written.value = Infinity;
237256 });
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;
240263 } catch (e) {
241264 lock();
242265 throw e;
......@@ -590,3 +613,4 @@ import type { ReadableStream } from "node:stream/web";
590613import { Lru } from "lib/Lru.ts";
591614import { Path } from "#sitegen/path";
592615import { 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 = {
2121 this site contains my art.
2222 </p>
2323 <h2>posts</h2>
24 <p>video collab: <a href="/history-of-japan-reanimated">history of japan reanimated</a> (2026-02-02)</p>
2425 <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>
2526 <p>song: <a href="/in-the-summer">in the summer</a> (2025-08-08)</p>
2627 <p>song: <a href="/waterfalls">waterfalls</a> (2025-01-01)</p>
src/tags/clover-video.client.ts+140-38
......@@ -2,6 +2,9 @@
22
33// Polyfill for old browsers.
44type PlaybackMode = "av1" | "hls-polyfill" | "hls-native" | "none";
5const forceMode = location.search.match(/(?:\?|&)force-player=(.*)/)?.[1] as
6 | PlaybackMode
7 | undefined;
58let mode: Promise<PlaybackMode> | null = null;
69let hls: Promise<typeof import("hls.js")> | null = null;
710
......@@ -13,6 +16,7 @@ Promise.allSettled ??= <T>(promises: Iterable<T | PromiseLike<T>>) =>
1316 (reason) => ({ status: "rejected" as const, reason }),
1417 )));
1518
19declare function hydrateVideo(container: HTMLElement): void;
1620globalThis.hydrateVideo = function hydrateVideo(container) {
1721 mode ??= decidePlaybackMode();
1822 mode.then((mode) => hydrateVideoInner(container, mode));
......@@ -23,9 +27,21 @@ Array.from(
2327 (container) => hydrateVideo(container),
2428);
2529
30// TODO: be less stupid about this
31const onCloverVideoInit = window.onCloverVideoInit;
32
2633function 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
2743 return shouldPlayAv1().catch(() => ({ hardware: false, supported: false }))
28 .then((av1): Awaitable<PlaybackMode> => {
44 .then((av1): PlaybackMode | Promise<PlaybackMode> => {
2945 // Native AV1 with DASH Polyfill
3046 // (Chrome v70, Safari v17 on M3/A17 chip, Firefox v67)
3147 if (av1.hardware) return "av1";
......@@ -43,6 +59,7 @@ function decidePlaybackMode(): Promise<PlaybackMode> {
4359 return "hls-native";
4460 }
4561
62 // @ts-expect-error
4663 return (hls ??= import("/js/scripts/vendor/hls.js"))
4764 .then((hls): PlaybackMode => {
4865 // Polyfill HLS (Chrome 23, Firefox 42, IE11 on Win8)
......@@ -59,80 +76,165 @@ function decidePlaybackMode(): Promise<PlaybackMode> {
5976 });
6077}
6178
62declare global {
63 function hydrateVideo(container: HTMLElement): void;
64}
65
6679function hydrateVideoInner(
6780 container: HTMLElement,
6881 mode: PlaybackMode,
6982) {
70 const video = container instanceof HTMLVideoElement
83 let video = container instanceof HTMLVideoElement
7184 ? container
7285 : container.querySelector("video")!;
86 const id = video.id;
7387 if (video.hasAttribute("controls")) return;
7488 let src = video.getAttribute("data-src");
7589 if (!src) return;
7690 src = `/file${src}`;
91 src = new URL(src, location.origin).href;
7792
7893 const dashFile = `${src}:/dash.mpd`;
7994 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
81103 if (mode === "av1") {
104 // @ts-expect-error
82105 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
85106 let mainSource: HTMLSourceElement | null = null;
86 Object.defineProperty(video, "src", {
87 get: () => false,
88 set: (v) => {
89 if (!mainSource) {
90 mainSource = document.createElement("source");
91 mainSource.src = v;
92 video.appendChild(mainSource);
93 } else {
94 mainSource.src = v;
95 }
96 },
107 let mediaSource: MediaSource | null = null, objectUrl = "";
108 const hasThumbnail = video.hasAttribute("poster");
109
110 const player = dashjs.MediaPlayer().create();
111
112 // the world is so simple when there is no thumbnail because we are not
113 // working around a billion sharp edges.
114 if (!hasThumbnail) {
115 enableSafariAirplay();
116 player.initialize(video, dashFile, false);
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 },
97130 });
98 Object.defineProperty(video, "disableRemotePlayback", {
99 get: () => false,
100 set: () => {
101 const source = document.createElement("source");
102 source.src = hlsFile;
103 video.appendChild(source);
104 },
131 player.preload();
132
133 if (typeof MediaSource !== "undefined") {
134 const duration = new Promise<number>((resolve) => {
135 player.on(dashjs.MediaPlayer.events.MANIFEST_LOADED, (e) => {
136 const duration = e.data.mediaPresentationDuration;
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);
105186 });
106 player.initialize(
107 video,
108 dashFile,
109 false, // autoplay
110 0, // start
111 );
112187 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 }
113213 });
114214 } else if (mode === "hls-native") {
115215 const hlsSource = document.createElement("source");
116 hlsSource.src = `${src}:/hls.m3u8`;
216 hlsSource.src = hlsFile;
117217 video.appendChild(hlsSource);
118218 video.controls = true;
219 onCloverVideoInit?.(id, video);
119220 } else if (mode === "hls-polyfill") {
120221 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)();
122225 player.loadSource(hlsFile);
123226 player.attachMedia(video);
124227 video.controls = true;
228 onCloverVideoInit?.(id, video);
125229 });
126230 } else {
127231 video.src = src;
128232 video.controls = true;
233 onCloverVideoInit?.(id, video);
129234 }
130235}
131236
132declare global {
133 var ManagedMediaSource: typeof MediaSource | null;
134}
135
237declare var ManagedMediaSource: typeof MediaSource | null;
136238function shouldPlayAv1(): Promise<{ hardware: boolean; supported: boolean }> {
137239 const MediaSource = globalThis.ManagedMediaSource ?? globalThis.MediaSource;
138240 if (!MediaSource.isTypeSupported('audio/webm;codecs="opus"')) {
src/tags/clover-video.css+1
......@@ -18,6 +18,7 @@
1818}
1919.clover-video video {
2020 width: 100%;
21 height: 100%;
2122}
2223.clover-video h2 {
2324 margin-top: 0;
src/tags/clover-video.marko+3-1
......@@ -1,6 +1,7 @@
11import "./clover-video.css";
22
33export interface Input {
4 id?: string;
45 file: MediaFile | string;
56 poster?: MediaFile | string;
67 header?: Marko.AttrTag<{ content: Marko.Body }>;
......@@ -8,7 +9,7 @@ export interface Input {
89 noFigure?: boolean
910}
1011
11<const/{ header, minimal, noFigure } = input />
12<const/{ id, header, minimal, noFigure } = input />
1213<const/file = typeof UNWRAP(input.file) === 'object'
1314 ? input.file
1415 : UNWRAP(MediaFile.getByPath(input.file), `No file ${JSON.stringify(input.file)}`) />
......@@ -22,6 +23,7 @@ export interface Input {
2223
2324<define/Inner>
2425 <video
26 ...{ id }
2527 class={ 'clover-video': noFigure }
2628 data-src=file.path
2729 poster=poster ? `/file${poster.path}` : null
tsconfig.json+1-1
......@@ -18,5 +18,5 @@
1818 "noUncheckedIndexedAccess": true,
1919 "resolveJsonModule": true
2020 },
21 "include": ["lib/**/*"]
21 "include": ["lib/**/*", "src/**/*", "framework/**/*"]
2222}