diff --git a/package.json b/package.json
index 6811d7e..8961342 100644
--- a/package.json
+++ b/package.json
@@ -10,7 +10,8 @@
"start": "next start",
"test": "pnpm build && playwright test",
"test:e2e": "playwright test",
- "test:e2e:ui": "playwright test --ui"
+ "test:e2e:ui": "playwright test --ui",
+ "optimize:banner": "node scripts/optimize-hero-banner.mjs"
},
"dependencies": {
"@headlessui/react": "2.1.2",
@@ -31,6 +32,7 @@
"@types/react": "^19",
"@types/react-dom": "^19",
"babel-plugin-react-compiler": "1.0.0",
+ "ffmpeg-static": "^5.3.0",
"pagefind": "^1.5.2",
"tailwindcss": "^4",
"typescript": "^5"
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index dfbb445..842dd55 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -57,6 +57,9 @@ importers:
babel-plugin-react-compiler:
specifier: 1.0.0
version: 1.0.0
+ ffmpeg-static:
+ specifier: ^5.3.0
+ version: 5.3.0
pagefind:
specifier: ^1.5.2
version: 1.5.2
@@ -94,6 +97,10 @@ packages:
'@chevrotain/types@11.1.2':
resolution: {integrity: sha512-U+HFai5+zmJCkK86QsaJtoITlboZHBqrVketcO2ROv865xfCMSFpELQoz1GkX5GzME8pTa+3kbKrZHQtI0gdbw==}
+ '@derhuerst/http-basic@8.2.4':
+ resolution: {integrity: sha512-F9rL9k9Xjf5blCz8HsJRO4diy111cayL2vkY2XE4r4t3n0yPXVYy3KD3nJ1qbrSn9743UWSXH4IwuCa/HWlGFw==}
+ engines: {node: '>=6.0.0'}
+
'@emnapi/runtime@1.10.0':
resolution: {integrity: sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==}
@@ -1017,6 +1024,9 @@ packages:
'@types/nlcst@2.0.3':
resolution: {integrity: sha512-vSYNSDe6Ix3q+6Z7ri9lyWqgGhJTmzRjZRqyq15N0Z/1/UnVsno9G/N40NBijoYx2seFDIl0+B2mgAb9mezUCA==}
+ '@types/node@10.17.60':
+ resolution: {integrity: sha512-F0KIgDJfy2nA3zMLmWGKxcH2ZVEtCZXHHdOQs2gSaQ27+lNeEfGxzkIw90aXswATX7AZ33tahPbzy6KAfUreVw==}
+
'@types/node@20.19.41':
resolution: {integrity: sha512-ECymXOukMnOoVkC2bb1Vc/w/836DXncOg5m8Xj1RH7xSHZJWNYY6Zh7EH477vcnD5egKNNfy2RpNOmuChhFPgQ==}
@@ -1062,6 +1072,10 @@ packages:
engines: {node: '>=0.4.0'}
hasBin: true
+ agent-base@6.0.2:
+ resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==}
+ engines: {node: '>= 6.0.0'}
+
arg@5.0.2:
resolution: {integrity: sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==}
@@ -1104,9 +1118,15 @@ packages:
resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==}
engines: {node: '>=8'}
+ buffer-from@1.1.2:
+ resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==}
+
caniuse-lite@1.0.30001793:
resolution: {integrity: sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA==}
+ caseless@0.12.0:
+ resolution: {integrity: sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw==}
+
ccount@2.0.1:
resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==}
@@ -1161,6 +1181,10 @@ packages:
compute-scroll-into-view@3.1.1:
resolution: {integrity: sha512-VRhuHOLoKYOy4UbilLbUzbYg93XLjv2PncJC50EuTWPA3gaja1UjBsUP/D/9/juV3vQFr6XBEzn9KCAHdUvOHw==}
+ concat-stream@2.0.0:
+ resolution: {integrity: sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==}
+ engines: {'0': node >= 6.0}
+
cose-base@1.0.3:
resolution: {integrity: sha512-s9whTXInMSgAp/NVXVNuVxVKzGH2qck3aQlVHxDCdAEPgtMKwc4Wq6/QKhgdEdgbLSi9rBTAcPoRa6JpiG4ksg==}
@@ -1370,6 +1394,10 @@ packages:
resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==}
engines: {node: '>=0.12'}
+ env-paths@2.2.1:
+ resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==}
+ engines: {node: '>=6'}
+
es-toolkit@1.46.1:
resolution: {integrity: sha512-5eNtXOs3tbfxXOj04tjjseeWkRWaoCjdEI+96DgwzZoe6c9juL49pXlzAFTI72aWC9Y8p7168g6XIKjh7k6pyQ==}
@@ -1437,6 +1465,10 @@ packages:
picomatch:
optional: true
+ ffmpeg-static@5.3.0:
+ resolution: {integrity: sha512-H+K6sW6TiIX6VGend0KQwthe+kaceeH/luE8dIZyOP35ik7ahYojDuqlTV1bOrtEwl01sy2HFNGQfi5IDJvotg==}
+ engines: {node: '>=16'}
+
fill-range@7.1.1:
resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==}
engines: {node: '>=8'}
@@ -1529,6 +1561,13 @@ packages:
html-void-elements@3.0.0:
resolution: {integrity: sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==}
+ http-response-object@3.0.2:
+ resolution: {integrity: sha512-bqX0XTF6fnXSQcEJ2Iuyr75yVakyjIDCqroJQ/aHfSdlM743Cwqoi2nDYMzLGWUcuTWGWy8AAvOKXTfiv6q9RA==}
+
+ https-proxy-agent@5.0.1:
+ resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==}
+ engines: {node: '>= 6'}
+
human-signals@5.0.0:
resolution: {integrity: sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ==}
engines: {node: '>=16.17.0'}
@@ -1540,6 +1579,9 @@ packages:
import-meta-resolve@4.2.0:
resolution: {integrity: sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg==}
+ inherits@2.0.4:
+ resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==}
+
inline-style-parser@0.2.7:
resolution: {integrity: sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==}
@@ -2015,6 +2057,9 @@ packages:
resolution: {integrity: sha512-XTUaK0hXMCu2jszWE584JGQT7y284TmMV9l/HX3rnG5uo3rHI/uHU56XTyyyPFjeWEBxECbAi0CaFDJOONtG0Q==}
hasBin: true
+ parse-cache-control@1.0.1:
+ resolution: {integrity: sha512-60zvsJReQPX5/QP0Kzfd/VrpjScIQ7SHBW6bFCYfEP+fp0Eppr1SHhIO5nd1PjZtvclzSzES9D/p5nFJurwfWg==}
+
parse-entities@4.0.2:
resolution: {integrity: sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==}
@@ -2076,6 +2121,10 @@ packages:
resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==}
engines: {node: ^10 || ^12 || >=14}
+ progress@2.0.3:
+ resolution: {integrity: sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==}
+ engines: {node: '>=0.4.0'}
+
property-information@7.1.0:
resolution: {integrity: sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==}
@@ -2113,6 +2162,10 @@ packages:
resolution: {integrity: sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==}
engines: {node: '>=0.10.0'}
+ readable-stream@3.6.2:
+ resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==}
+ engines: {node: '>= 6'}
+
reading-time@1.5.0:
resolution: {integrity: sha512-onYyVhBNr4CmAxFsKS7bz+uTLRakypIe4R+5A824vBSkQy/hB3fZepoVEf8OVAxzLvK+H/jm9TzpI3ETSm64Kg==}
@@ -2213,6 +2266,9 @@ packages:
rw@1.3.3:
resolution: {integrity: sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==}
+ safe-buffer@5.2.1:
+ resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==}
+
safer-buffer@2.1.2:
resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==}
@@ -2268,6 +2324,9 @@ packages:
resolution: {integrity: sha512-i/VCLG1fvRc95pMHRqG4aQNscv+9aIsqA2oI7ZQS51sTdUcDHYX6cpT8/tqZ+enjs1tKVwbRBWgxut9SWn+f9g==}
hasBin: true
+ string_decoder@1.3.0:
+ resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==}
+
stringify-entities@4.0.4:
resolution: {integrity: sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==}
@@ -2351,6 +2410,9 @@ packages:
peerDependencies:
typescript: ^5.5.0 || ^6.0.0
+ typedarray@0.0.6:
+ resolution: {integrity: sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==}
+
typescript@5.9.3:
resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
engines: {node: '>=14.17'}
@@ -2400,6 +2462,9 @@ packages:
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
+ util-deprecate@1.0.2:
+ resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
+
uuid@14.0.0:
resolution: {integrity: sha512-Qo+uWgilfSmAhXCMav1uYFynlQO7fMFiMVZsQqZRMIXp0O7rR7qjkj+cPvBHLgBqi960QCoo/PH2/6ZtVqKvrg==}
hasBin: true
@@ -2475,6 +2540,13 @@ snapshots:
'@chevrotain/types@11.1.2': {}
+ '@derhuerst/http-basic@8.2.4':
+ dependencies:
+ caseless: 0.12.0
+ concat-stream: 2.0.0
+ http-response-object: 3.0.2
+ parse-cache-control: 1.0.1
+
'@emnapi/runtime@1.10.0':
dependencies:
tslib: 2.8.1
@@ -3336,6 +3408,8 @@ snapshots:
dependencies:
'@types/unist': 3.0.3
+ '@types/node@10.17.60': {}
+
'@types/node@20.19.41':
dependencies:
undici-types: 6.21.0
@@ -3377,6 +3451,12 @@ snapshots:
acorn@8.16.0: {}
+ agent-base@6.0.2:
+ dependencies:
+ debug: 4.4.3
+ transitivePeerDependencies:
+ - supports-color
+
arg@5.0.2: {}
aria-hidden@1.2.6:
@@ -3410,8 +3490,12 @@ snapshots:
dependencies:
fill-range: 7.1.1
+ buffer-from@1.1.2: {}
+
caniuse-lite@1.0.30001793: {}
+ caseless@0.12.0: {}
+
ccount@2.0.1: {}
chalk@5.6.2: {}
@@ -3448,6 +3532,13 @@ snapshots:
compute-scroll-into-view@3.1.1: {}
+ concat-stream@2.0.0:
+ dependencies:
+ buffer-from: 1.1.2
+ inherits: 2.0.4
+ readable-stream: 3.6.2
+ typedarray: 0.0.6
+
cose-base@1.0.3:
dependencies:
layout-base: 1.0.2
@@ -3681,6 +3772,8 @@ snapshots:
entities@6.0.1: {}
+ env-paths@2.2.1: {}
+
es-toolkit@1.46.1: {}
esast-util-from-estree@2.0.0:
@@ -3772,6 +3865,15 @@ snapshots:
optionalDependencies:
picomatch: 4.0.4
+ ffmpeg-static@5.3.0:
+ dependencies:
+ '@derhuerst/http-basic': 8.2.4
+ env-paths: 2.2.1
+ https-proxy-agent: 5.0.1
+ progress: 2.0.3
+ transitivePeerDependencies:
+ - supports-color
+
fill-range@7.1.1:
dependencies:
to-regex-range: 5.0.1
@@ -3949,6 +4051,17 @@ snapshots:
html-void-elements@3.0.0: {}
+ http-response-object@3.0.2:
+ dependencies:
+ '@types/node': 10.17.60
+
+ https-proxy-agent@5.0.1:
+ dependencies:
+ agent-base: 6.0.2
+ debug: 4.4.3
+ transitivePeerDependencies:
+ - supports-color
+
human-signals@5.0.0: {}
iconv-lite@0.6.3:
@@ -3957,6 +4070,8 @@ snapshots:
import-meta-resolve@4.2.0: {}
+ inherits@2.0.4: {}
+
inline-style-parser@0.2.7: {}
internmap@1.0.1: {}
@@ -4745,6 +4860,8 @@ snapshots:
'@pagefind/windows-arm64': 1.5.2
'@pagefind/windows-x64': 1.5.2
+ parse-cache-control@1.0.1: {}
+
parse-entities@4.0.2:
dependencies:
'@types/unist': 2.0.11
@@ -4811,6 +4928,8 @@ snapshots:
picocolors: 1.1.1
source-map-js: 1.2.1
+ progress@2.0.3: {}
+
property-information@7.1.0: {}
queue-microtask@1.2.3: {}
@@ -4855,6 +4974,12 @@ snapshots:
react@19.2.4: {}
+ readable-stream@3.6.2:
+ dependencies:
+ inherits: 2.0.4
+ string_decoder: 1.3.0
+ util-deprecate: 1.0.2
+
reading-time@1.5.0: {}
recma-build-jsx@1.0.0:
@@ -5051,6 +5176,8 @@ snapshots:
rw@1.3.3: {}
+ safe-buffer@5.2.1: {}
+
safer-buffer@2.1.2: {}
scheduler@0.27.0: {}
@@ -5129,6 +5256,10 @@ snapshots:
commander: 13.1.0
wicked-good-xpath: 1.3.0
+ string_decoder@1.3.0:
+ dependencies:
+ safe-buffer: 5.2.1
+
stringify-entities@4.0.4:
dependencies:
character-entities-html4: 2.1.0
@@ -5199,6 +5330,8 @@ snapshots:
transitivePeerDependencies:
- supports-color
+ typedarray@0.0.6: {}
+
typescript@5.9.3: {}
undici-types@6.21.0: {}
@@ -5269,6 +5402,8 @@ snapshots:
dependencies:
react: 19.2.4
+ util-deprecate@1.0.2: {}
+
uuid@14.0.0: {}
vfile-location@5.0.3:
diff --git a/public/banner.mp4 b/public/banner.mp4
new file mode 100644
index 0000000..5164e27
Binary files /dev/null and b/public/banner.mp4 differ
diff --git a/public/banner.webm b/public/banner.webm
new file mode 100644
index 0000000..0a7f426
Binary files /dev/null and b/public/banner.webm differ
diff --git a/scripts/optimize-hero-banner.mjs b/scripts/optimize-hero-banner.mjs
new file mode 100644
index 0000000..6bbe423
--- /dev/null
+++ b/scripts/optimize-hero-banner.mjs
@@ -0,0 +1,74 @@
+/**
+ * 将 public/banner.mp4(或 banner.source.mp4)压成首屏 Hero 尺寸。
+ * 用法:pnpm run optimize:banner
+ * 可选:把未压缩母带重命名为 banner.source.mp4,脚本会优先读取它并写回 banner.mp4。
+ */
+import { execFileSync } from "node:child_process";
+import fs from "node:fs";
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+import ffmpegPath from "ffmpeg-static";
+
+const root = path.join(path.dirname(fileURLToPath(import.meta.url)), "..");
+const publicDir = path.join(root, "public");
+const sourceCandidates = [
+ path.join(publicDir, "banner.source.mp4"),
+ path.join(publicDir, "banner.mp4"),
+];
+const input = sourceCandidates.find((p) => fs.existsSync(p));
+
+if (!input || !ffmpegPath) {
+ console.error("缺少 banner.mp4 / banner.source.mp4 或 ffmpeg 二进制。");
+ process.exit(1);
+}
+
+const outMp4 = path.join(publicDir, "banner.mp4");
+const outWebm = path.join(publicDir, "banner.webm");
+const tmpMp4 = path.join(publicDir, "banner.opt.mp4");
+
+const scale = "scale=1920:-2";
+const common = ["-y", "-i", input, "-an", "-vf", scale, "-pix_fmt", "yuv420p"];
+
+console.log(`输入: ${input}`);
+
+execFileSync(
+ ffmpegPath,
+ [
+ ...common,
+ "-c:v",
+ "libx264",
+ "-preset",
+ "slow",
+ "-crf",
+ "28",
+ "-movflags",
+ "+faststart",
+ tmpMp4,
+ ],
+ { stdio: "inherit" },
+);
+
+execFileSync(
+ ffmpegPath,
+ [
+ ...common,
+ "-c:v",
+ "libvpx-vp9",
+ "-crf",
+ "35",
+ "-b:v",
+ "0",
+ "-row-mt",
+ "1",
+ outWebm,
+ ],
+ { stdio: "inherit" },
+);
+
+fs.copyFileSync(tmpMp4, outMp4);
+fs.unlinkSync(tmpMp4);
+
+for (const f of [outMp4, outWebm]) {
+ const kb = (fs.statSync(f).size / 1024).toFixed(1);
+ console.log(`${path.basename(f)}: ${kb} KiB`);
+}
diff --git a/src/components/hero-banner-video.tsx b/src/components/hero-banner-video.tsx
new file mode 100644
index 0000000..10e8ac0
--- /dev/null
+++ b/src/components/hero-banner-video.tsx
@@ -0,0 +1,163 @@
+"use client";
+
+import Image from "next/image";
+import { useReducedMotion } from "motion/react";
+import { useEffect, useRef } from "react";
+
+const POSTER = "/banner.png";
+const WEBM = "/banner.webm";
+const MP4 = "/banner.mp4";
+
+const videoClassName =
+ "pointer-events-none absolute inset-0 h-full w-full object-cover object-center";
+
+function HeroBannerSources() {
+ return (
+ <>
+
+
+ >
+ );
+}
+
+function seekToStart(video: HTMLVideoElement) {
+ if (typeof video.fastSeek === "function") {
+ try {
+ video.fastSeek(0);
+ return;
+ } catch {
+ // fall through
+ }
+ }
+ video.currentTime = 0;
+}
+
+function primeVideo(el: HTMLVideoElement, preload: "auto" | "none") {
+ el.muted = true;
+ el.playsInline = true;
+ el.preload = preload;
+}
+
+/**
+ * 双轨在 ended 时刻精确切换:不提前截断,保证末帧与首帧对齐。
+ * 副轨 preload=none,仅在接力前加载,减少首屏带宽与解码占用。
+ */
+export function HeroBannerVideo() {
+ const reduceMotion = useReducedMotion();
+ const refA = useRef(null);
+ const refB = useRef(null);
+
+ useEffect(() => {
+ if (reduceMotion) return;
+
+ const videoA = refA.current;
+ const videoB = refB.current;
+ if (!videoA || !videoB) return;
+
+ primeVideo(videoA, "auto");
+ primeVideo(videoB, "none");
+
+ let active: HTMLVideoElement = videoA;
+ let idle: HTMLVideoElement = videoB;
+ let idlePrimed = false;
+
+ const show = (el: HTMLVideoElement, visible: boolean) => {
+ el.style.opacity = visible ? "1" : "0";
+ };
+
+ show(videoA, true);
+ show(videoB, false);
+
+ const primeIdle = () => {
+ if (idlePrimed) return;
+ idlePrimed = true;
+ idle.preload = "auto";
+ idle.load();
+ };
+
+ const onEnded = (endedVideo: HTMLVideoElement) => {
+ if (endedVideo !== active) return;
+
+ seekToStart(endedVideo);
+ endedVideo.pause();
+ show(endedVideo, false);
+
+ active = idle;
+ idle = endedVideo;
+
+ primeIdle();
+ seekToStart(active);
+ show(active, true);
+ void active.play().catch(() => {});
+ };
+
+ const handleEndedA = () => onEnded(videoA);
+ const handleEndedB = () => onEnded(videoB);
+
+ const warmIdleBeforeHandoff = (ev: Event) => {
+ const current = ev.currentTarget as HTMLVideoElement;
+ if (current !== active) return;
+ const duration = current.duration;
+ if (!Number.isFinite(duration) || duration <= 0) return;
+ if (duration - current.currentTime <= 1.5) {
+ primeIdle();
+ }
+ };
+
+ videoA.addEventListener("ended", handleEndedA);
+ videoB.addEventListener("ended", handleEndedB);
+ videoA.addEventListener("timeupdate", warmIdleBeforeHandoff);
+ videoB.addEventListener("timeupdate", warmIdleBeforeHandoff);
+
+ void videoA.play().catch(() => {});
+
+ return () => {
+ videoA.removeEventListener("ended", handleEndedA);
+ videoB.removeEventListener("ended", handleEndedB);
+ videoA.removeEventListener("timeupdate", warmIdleBeforeHandoff);
+ videoB.removeEventListener("timeupdate", warmIdleBeforeHandoff);
+ };
+ }, [reduceMotion]);
+
+ if (reduceMotion === true) {
+ return (
+
+ );
+ }
+
+ return (
+ <>
+
+
+ >
+ );
+}
diff --git a/src/components/hero-section.tsx b/src/components/hero-section.tsx
index e46c686..7f7ccbf 100644
--- a/src/components/hero-section.tsx
+++ b/src/components/hero-section.tsx
@@ -1,4 +1,4 @@
-import Image from "next/image";
+import { HeroBannerVideo } from "@/components/hero-banner-video";
import { site } from "@/content/site";
export function HeroSection() {
@@ -8,14 +8,7 @@ export function HeroSection() {
className="hero-section relative isolate z-0 overflow-hidden"
id="top"
>
-
+