diff --git a/.gitignore b/.gitignore
index c46827399..b7c7f2cf0 100644
--- a/.gitignore
+++ b/.gitignore
@@ -35,6 +35,12 @@ src/reportIdMap.js
src/imageDimensions.js
src/data/
+# Preserved raw originals from compress-video (kept locally, never deployed)
+*-raw.mp4
+*-raw.webm
+*-raw.mov
+*-raw.m4v
+
/docx_converter/venv/
/docx_converter/utils/__pycache__/
*.py[cod]
diff --git a/docs/dev/documentation-guide/06-creating-editing-pages.mdx b/docs/dev/documentation-guide/06-creating-editing-pages.mdx
index 43790d487..fd397860f 100644
--- a/docs/dev/documentation-guide/06-creating-editing-pages.mdx
+++ b/docs/dev/documentation-guide/06-creating-editing-pages.mdx
@@ -582,7 +582,6 @@ import Video from '@site/src/components/Video';
```
@@ -595,6 +594,16 @@ import VideoReference from '@site/src/components/VideoReference';
See for a walkthrough.
```
+:::tip Compress videos before committing
+Screen-capture files are often much larger than they need to be. Before adding a video, compress it with the built-in tool:
+
+```bash
+npm run compress-video -- static/figures//your-video.mp4
+```
+
+This re-encodes the file in place (so your `` path stays the same) and preserves the untouched original beside it as `your-video-raw.mp4`. The `*-raw.*` originals are gitignored — kept locally as a backup, never committed or deployed. Add `--audio` to keep an audio track (dropped by default) or `--crf ` to adjust quality.
+:::
+
See [React Components](./07-react-components.mdx) for full props documentation.
---
diff --git a/docs/dev/documentation-guide/07-react-components.mdx b/docs/dev/documentation-guide/07-react-components.mdx
index d3c8ecd32..8d3214b80 100644
--- a/docs/dev/documentation-guide/07-react-components.mdx
+++ b/docs/dev/documentation-guide/07-react-components.mdx
@@ -3105,7 +3105,7 @@ import Video from '@site/src/components/Video';
'Multiple sources [{src, type}]',
'Display width',
'Show video controls',
- 'Auto-play on load',
+ 'Auto-play when scrolled into view (muted); pauses off-screen; off for reduced-motion users',
'Loop playback',
'Mute audio (auto=true if autoPlay)',
'iOS inline playback',
@@ -3118,6 +3118,27 @@ import Video from '@site/src/components/Video';
]}
/>
+**Autoplay behavior:**
+
+When `autoPlay` is set, the video does **not** start on page load. It starts when the user scrolls it into view and pauses when it scrolls out of view (via an `IntersectionObserver`). Autoplay always plays **muted** (browsers block un-muted autoplay), and it is **disabled entirely for visitors who have enabled "reduce motion"** in their operating system.
+
+Use `autoPlay loop` with `controls={false}` as a lightweight, modern replacement for an animated GIF — the same continuous, silent loop, but a far smaller file with built-in pause-when-offscreen behavior:
+
+```jsx
+
+```
+
+:::warning Keep controls on for longer autoplay clips
+For an `autoPlay loop` clip longer than ~5 seconds, leave `controls` at its default (`true`) so viewers can pause it. Continuous motion with no pause control fails WCAG 2.2.2 (Pause, Stop, Hide), a Section 508 requirement. The `controls={false}` GIF style is only appropriate for very short loops.
+:::
+
---
### VideoReference
diff --git a/docs/dev/documentation-guide/17-appendix-b-build-process-overview.mdx b/docs/dev/documentation-guide/17-appendix-b-build-process-overview.mdx
index a2674fa66..d059597b1 100644
--- a/docs/dev/documentation-guide/17-appendix-b-build-process-overview.mdx
+++ b/docs/dev/documentation-guide/17-appendix-b-build-process-overview.mdx
@@ -601,7 +601,7 @@ These files configure the build process but are rarely modified:
**Contains:**
- List of all npm packages used
-- Script definitions (`start`, `build`, `deploy`)
+- Script definitions (`start`, `build`, `deploy`, and the manual `compress-video` utility)
- Node.js version requirements
- Project metadata
@@ -625,6 +625,14 @@ These files configure the build process but are rarely modified:
---
+### `scripts/compress-video.js` (manual utility)
+
+**Purpose:** Compress oversized video files before committing them.
+
+Unlike the scripts above, this is **not** part of the automated build — it is an on-demand helper. Run `npm run compress-video -- ` to re-encode a video in place (H.264/CRF via the bundled `ffmpeg-static`, so no system install is required). The original is preserved as a gitignored `*-raw.*` file. See [Videos and GIFs](./06-creating-editing-pages.mdx) for the contributor workflow.
+
+---
+
## Deployment to Production (Administrator-Level Only)
:::danger Important - Administrators Only
diff --git a/package-lock.json b/package-lock.json
index b79778a75..3d0e9e161 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -40,6 +40,7 @@
"autoprefixer": "^10.4.21",
"baseline-browser-mapping": "^2.9.19",
"cross-env": "^7.0.3",
+ "ffmpeg-static": "^5.3.0",
"postcss": "^8.5.6",
"prettier": "^3.6.2",
"prettier-plugin-tailwindcss": "^0.6.14"
@@ -3372,6 +3373,22 @@
"postcss": "^8.4"
}
},
+ "node_modules/@derhuerst/http-basic": {
+ "version": "8.2.4",
+ "resolved": "https://registry.npmjs.org/@derhuerst/http-basic/-/http-basic-8.2.4.tgz",
+ "integrity": "sha512-F9rL9k9Xjf5blCz8HsJRO4diy111cayL2vkY2XE4r4t3n0yPXVYy3KD3nJ1qbrSn9743UWSXH4IwuCa/HWlGFw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "caseless": "^0.12.0",
+ "concat-stream": "^2.0.0",
+ "http-response-object": "^3.0.1",
+ "parse-cache-control": "^1.0.1"
+ },
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
"node_modules/@discoveryjs/json-ext": {
"version": "0.5.7",
"resolved": "https://registry.npmjs.org/@discoveryjs/json-ext/-/json-ext-0.5.7.tgz",
@@ -6617,6 +6634,19 @@
"node": ">= 10.0.0"
}
},
+ "node_modules/agent-base": {
+ "version": "6.0.2",
+ "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
+ "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "debug": "4"
+ },
+ "engines": {
+ "node": ">= 6.0.0"
+ }
+ },
"node_modules/aggregate-error": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-3.1.0.tgz",
@@ -7428,6 +7458,13 @@
],
"license": "CC-BY-4.0"
},
+ "node_modules/caseless": {
+ "version": "0.12.0",
+ "resolved": "https://registry.npmjs.org/caseless/-/caseless-0.12.0.tgz",
+ "integrity": "sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw==",
+ "dev": true,
+ "license": "Apache-2.0"
+ },
"node_modules/ccount": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz",
@@ -7901,6 +7938,22 @@
"integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==",
"license": "MIT"
},
+ "node_modules/concat-stream": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz",
+ "integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==",
+ "dev": true,
+ "engines": [
+ "node >= 6.0"
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "buffer-from": "^1.0.0",
+ "inherits": "^2.0.3",
+ "readable-stream": "^3.0.2",
+ "typedarray": "^0.0.6"
+ }
+ },
"node_modules/confbox": {
"version": "0.2.2",
"resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.2.tgz",
@@ -9642,6 +9695,16 @@
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
+ "node_modules/env-paths": {
+ "version": "2.2.1",
+ "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz",
+ "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
"node_modules/error-ex": {
"version": "1.3.4",
"resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz",
@@ -10208,6 +10271,23 @@
"node": ">=0.4.0"
}
},
+ "node_modules/ffmpeg-static": {
+ "version": "5.3.0",
+ "resolved": "https://registry.npmjs.org/ffmpeg-static/-/ffmpeg-static-5.3.0.tgz",
+ "integrity": "sha512-H+K6sW6TiIX6VGend0KQwthe+kaceeH/luE8dIZyOP35ik7ahYojDuqlTV1bOrtEwl01sy2HFNGQfi5IDJvotg==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "GPL-3.0-or-later",
+ "dependencies": {
+ "@derhuerst/http-basic": "^8.2.0",
+ "env-paths": "^2.2.0",
+ "https-proxy-agent": "^5.0.0",
+ "progress": "^2.0.3"
+ },
+ "engines": {
+ "node": ">=16"
+ }
+ },
"node_modules/figures": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/figures/-/figures-3.2.0.tgz",
@@ -11570,6 +11650,23 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/http-response-object": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/http-response-object/-/http-response-object-3.0.2.tgz",
+ "integrity": "sha512-bqX0XTF6fnXSQcEJ2Iuyr75yVakyjIDCqroJQ/aHfSdlM743Cwqoi2nDYMzLGWUcuTWGWy8AAvOKXTfiv6q9RA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/node": "^10.0.3"
+ }
+ },
+ "node_modules/http-response-object/node_modules/@types/node": {
+ "version": "10.17.60",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-10.17.60.tgz",
+ "integrity": "sha512-F0KIgDJfy2nA3zMLmWGKxcH2ZVEtCZXHHdOQs2gSaQ27+lNeEfGxzkIw90aXswATX7AZ33tahPbzy6KAfUreVw==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/http2-wrapper": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/http2-wrapper/-/http2-wrapper-2.2.1.tgz",
@@ -11583,6 +11680,20 @@
"node": ">=10.19.0"
}
},
+ "node_modules/https-proxy-agent": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz",
+ "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "agent-base": "6",
+ "debug": "4"
+ },
+ "engines": {
+ "node": ">= 6"
+ }
+ },
"node_modules/human-signals": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz",
@@ -16005,6 +16116,12 @@
"node": ">=6"
}
},
+ "node_modules/parse-cache-control": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/parse-cache-control/-/parse-cache-control-1.0.1.tgz",
+ "integrity": "sha512-60zvsJReQPX5/QP0Kzfd/VrpjScIQ7SHBW6bFCYfEP+fp0Eppr1SHhIO5nd1PjZtvclzSzES9D/p5nFJurwfWg==",
+ "dev": true
+ },
"node_modules/parse-entities": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-4.0.2.tgz",
@@ -17978,6 +18095,16 @@
"integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
"license": "MIT"
},
+ "node_modules/progress": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz",
+ "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.4.0"
+ }
+ },
"node_modules/prompts": {
"version": "2.4.2",
"resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz",
@@ -20651,6 +20778,13 @@
"node": ">= 0.6"
}
},
+ "node_modules/typedarray": {
+ "version": "0.0.6",
+ "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz",
+ "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/typedarray-to-buffer": {
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/typedarray-to-buffer/-/typedarray-to-buffer-3.1.5.tgz",
diff --git a/package.json b/package.json
index a10f2f99b..337ab860c 100644
--- a/package.json
+++ b/package.json
@@ -8,6 +8,7 @@
"versions": "node scripts/versions.js",
"sidebars": "node scripts/generateSidebars.js",
"report-map": "node scripts/generateReportIdMap.js",
+ "compress-video": "node scripts/compress-video.js",
"generate-toc": "node scripts/generateEventTreeTOC.js",
"image-dims": "node scripts/generateImageDimensions.js",
"start": "cross-env DOCS_MODE=dev npm run image-dims && cross-env DOCS_MODE=dev npm run report-map && cross-env DOCS_MODE=dev npm run sidebars && cross-env DOCS_MODE=dev npm run counters && cross-env DOCS_MODE=dev npm run versions && cross-env DOCS_MODE=dev npm run generate-toc && cross-env DOCS_MODE=dev docusaurus start",
@@ -52,6 +53,7 @@
"autoprefixer": "^10.4.21",
"baseline-browser-mapping": "^2.9.19",
"cross-env": "^7.0.3",
+ "ffmpeg-static": "^5.3.0",
"postcss": "^8.5.6",
"prettier": "^3.6.2",
"prettier-plugin-tailwindcss": "^0.6.14"
diff --git a/scripts/compress-video.js b/scripts/compress-video.js
new file mode 100644
index 000000000..849add9db
--- /dev/null
+++ b/scripts/compress-video.js
@@ -0,0 +1,249 @@
+/**
+ * compress-video.js
+ *
+ * Compresses video files for the documentation site using the bundled ffmpeg
+ * binary (ffmpeg-static) — no system install required.
+ *
+ * Strategy:
+ * - Quality-based H.264 encode (CRF) so bitrate is derived from the content,
+ * not a fixed number. This is what actually shrinks over-encoded clips.
+ * - The clean filename always becomes the compressed, web-ready version (so it
+ * can be referenced directly in MDX). The untouched original is preserved
+ * alongside it with a "-raw" suffix, which .gitignore keeps out of git and
+ * off the deployed site.
+ *
+ * Usage:
+ * npm run compress-video -- [more...] [options]
+ *
+ * Examples:
+ * npm run compress-video -- static/figures/.../Media2.mp4
+ * npm run compress-video -- "static/figures//*.mp4"
+ * npm run compress-video -- static/figures/.../v1.0 --audio
+ *
+ * Options:
+ * --audio Keep the audio track (re-encoded to AAC ~96k). Default: drop.
+ * --crf Quality (0-51, lower = better/larger). Default: 26.
+ * --help Show this help.
+ */
+
+const fs = require('fs');
+const path = require('path');
+const { spawnSync } = require('child_process');
+const { globSync } = require('glob');
+const ffmpegPath = require('ffmpeg-static');
+
+// Extensions we accept as input. Output is always .mp4 (the clean web file).
+const VIDEO_EXTS = ['.mp4', '.mov', '.m4v', '.webm', '.mkv', '.avi'];
+const RAW_SUFFIX = '-raw';
+
+// ---------------------------------------------------------------------------
+// Argument parsing
+// ---------------------------------------------------------------------------
+
+function parseArgs(argv) {
+ const opts = { crf: 26, audio: false, help: false };
+ const inputs = [];
+
+ for (let i = 0; i < argv.length; i++) {
+ const arg = argv[i];
+ if (arg === '--help' || arg === '-h') {
+ opts.help = true;
+ } else if (arg === '--audio') {
+ opts.audio = true;
+ } else if (arg === '--crf') {
+ const val = parseInt(argv[++i], 10);
+ if (Number.isNaN(val) || val < 0 || val > 51) {
+ fail(`--crf must be an integer 0-51 (got "${argv[i]}")`);
+ }
+ opts.crf = val;
+ } else if (arg.startsWith('--')) {
+ fail(`Unknown option "${arg}". Use --help to see available options.`);
+ } else {
+ inputs.push(arg);
+ }
+ }
+
+ return { opts, inputs };
+}
+
+function fail(message) {
+ console.error(`\n Error: ${message}\n`);
+ process.exit(1);
+}
+
+function printHelp() {
+ console.log(
+ [
+ '',
+ ' Compress videos for the documentation site.',
+ '',
+ ' Usage:',
+ ' npm run compress-video -- [more...] [options]',
+ '',
+ ' Options:',
+ ' --audio Keep the audio track (AAC ~96k). Default: drop audio.',
+ ' --crf Quality 0-51, lower = better/larger. Default: 26.',
+ ' --help Show this help.',
+ '',
+ ].join('\n')
+ );
+}
+
+// ---------------------------------------------------------------------------
+// Input resolution
+// ---------------------------------------------------------------------------
+
+const isVideo = (file) => VIDEO_EXTS.includes(path.extname(file).toLowerCase());
+const isRaw = (file) => path.basename(file, path.extname(file)).toLowerCase().endsWith(RAW_SUFFIX);
+
+// Expand each input (file, glob, or directory) into a flat list of video files,
+// excluding preserved "-raw" originals so they are never re-compressed.
+function resolveInputs(inputs) {
+ const files = new Set();
+
+ for (const input of inputs) {
+ let matches = [];
+ if (fs.existsSync(input) && fs.statSync(input).isDirectory()) {
+ // Directory: recurse for any supported video file.
+ const pattern = `${input.replace(/\\/g, '/')}/**/*.{${VIDEO_EXTS.map((e) => e.slice(1)).join(',')}}`;
+ matches = globSync(pattern, { nodir: true });
+ } else if (/[*?[\]{}]/.test(input)) {
+ // Glob pattern.
+ matches = globSync(input.replace(/\\/g, '/'), { nodir: true });
+ } else if (fs.existsSync(input)) {
+ matches = [input];
+ } else {
+ console.warn(` Skipping (not found): ${input}`);
+ continue;
+ }
+
+ for (const m of matches) {
+ if (!isVideo(m)) continue;
+ if (isRaw(m)) continue; // never feed preserved originals back in
+ files.add(path.resolve(m));
+ }
+ }
+
+ return [...files];
+}
+
+// ---------------------------------------------------------------------------
+// Compression
+// ---------------------------------------------------------------------------
+
+const fmtMB = (bytes) => `${(bytes / 1024 / 1024).toFixed(2)} MB`;
+
+function buildFfmpegArgs(srcPath, tmpPath, opts) {
+ const args = ['-y', '-i', srcPath, '-c:v', 'libx264', '-crf', String(opts.crf), '-preset', 'slow', '-pix_fmt', 'yuv420p', '-movflags', '+faststart'];
+
+ if (opts.audio) {
+ args.push('-c:a', 'aac', '-b:a', '96k');
+ } else {
+ args.push('-an');
+ }
+
+ args.push(tmpPath);
+ return args;
+}
+
+// Returns 'compressed' | 'skipped' | 'no-gain' | 'failed'
+function compressOne(srcPath, opts) {
+ const dir = path.dirname(srcPath);
+ const ext = path.extname(srcPath);
+ const base = path.basename(srcPath, ext);
+ const rel = path.relative(process.cwd(), srcPath);
+
+ // Re-run guard: if any " -raw.*" already exists, this clip was already
+ // converted. Skip so we never clobber a preserved original.
+ const existingRaw = globSync(`${dir.replace(/\\/g, '/')}/${base}${RAW_SUFFIX}.*`, { nodir: true });
+ if (existingRaw.length > 0) {
+ console.log(` ⏭ ${rel} — already has ${path.basename(existingRaw[0])}, skipping`);
+ return 'skipped';
+ }
+
+ const inputSize = fs.statSync(srcPath).size;
+ const tmpPath = path.join(dir, `${base}.compressing.mp4`);
+
+ const result = spawnSync(ffmpegPath, buildFfmpegArgs(srcPath, tmpPath, opts), { stdio: ['ignore', 'ignore', 'pipe'] });
+
+ if (result.status !== 0) {
+ if (fs.existsSync(tmpPath)) fs.rmSync(tmpPath);
+ console.error(` ✗ ${rel} — ffmpeg failed`);
+ if (result.stderr) console.error(String(result.stderr).trim().split('\n').slice(-3).join('\n'));
+ return 'failed';
+ }
+
+ const outputSize = fs.statSync(tmpPath).size;
+
+ // Keep-smaller safety: if we didn't actually shrink it, discard the result
+ // and leave the original untouched (no -raw created).
+ if (outputSize >= inputSize) {
+ fs.rmSync(tmpPath);
+ console.log(` = ${rel} — no gain (${fmtMB(inputSize)}), left unchanged`);
+ return 'no-gain';
+ }
+
+ // Preserve the original as " -raw", then promote the
+ // compressed file to the clean " .mp4" name for use in MDX.
+ const rawPath = path.join(dir, `${base}${RAW_SUFFIX}${ext}`);
+ const finalPath = path.join(dir, `${base}.mp4`);
+
+ fs.renameSync(srcPath, rawPath);
+ // If the source wasn't already .mp4, srcPath !== finalPath, so finalPath is free.
+ fs.renameSync(tmpPath, finalPath);
+
+ const pct = (100 * (1 - outputSize / inputSize)).toFixed(0);
+ console.log(` ✓ ${rel} — ${fmtMB(inputSize)} → ${fmtMB(outputSize)} (−${pct}%) [original kept as ${path.basename(rawPath)}]`);
+ return 'compressed';
+}
+
+// ---------------------------------------------------------------------------
+// Main
+// ---------------------------------------------------------------------------
+
+function main() {
+ const { opts, inputs } = parseArgs(process.argv.slice(2));
+
+ if (opts.help) {
+ printHelp();
+ return;
+ }
+ if (inputs.length === 0) {
+ printHelp();
+ fail('No input files specified.');
+ }
+ if (!ffmpegPath || !fs.existsSync(ffmpegPath)) {
+ fail('ffmpeg binary not found. Run `npm install` to fetch ffmpeg-static.');
+ }
+
+ const files = resolveInputs(inputs);
+ if (files.length === 0) {
+ fail('No matching video files found (note: "-raw" originals are excluded).');
+ }
+
+ console.log(`\n Compressing ${files.length} video(s) — CRF ${opts.crf}, audio ${opts.audio ? 'kept' : 'dropped'}\n`);
+
+ const tally = { compressed: 0, skipped: 0, 'no-gain': 0, failed: 0 };
+ let savedBytes = 0;
+
+ for (const file of files) {
+ const before = fs.existsSync(file) ? fs.statSync(file).size : 0;
+ const outcome = compressOne(file, opts);
+ tally[outcome]++;
+ if (outcome === 'compressed') {
+ const rawPath = path.join(path.dirname(file), `${path.basename(file, path.extname(file))}${RAW_SUFFIX}${path.extname(file)}`);
+ const after = fs.statSync(path.join(path.dirname(file), `${path.basename(file, path.extname(file))}.mp4`)).size;
+ savedBytes += before - after;
+ void rawPath;
+ }
+ }
+
+ console.log(
+ `\n Done: ${tally.compressed} compressed, ${tally['no-gain']} no-gain, ${tally.skipped} skipped, ${tally.failed} failed` +
+ (savedBytes > 0 ? ` — saved ${fmtMB(savedBytes)} total\n` : '\n')
+ );
+
+ if (tally.failed > 0) process.exit(1);
+}
+
+main();
diff --git a/src/components/Video.js b/src/components/Video.js
index 03fd9e7df..aa917b57b 100644
--- a/src/components/Video.js
+++ b/src/components/Video.js
@@ -1,5 +1,5 @@
import useBaseUrl from '@docusaurus/useBaseUrl';
-import { useEffect, useState } from 'react';
+import { useEffect, useRef, useState } from 'react';
import { useReportId } from '../contexts/ReportIdContext';
import '../css/custom.css';
@@ -12,7 +12,7 @@ const Video = ({
width = '80%',
id,
controls, // Show/hide browser video controls, default: true (on)
- autoPlay, // Start playback automatically, default: off
+ autoPlay, // Auto-start when scrolled into view (muted); paused when scrolled away. Honors prefers-reduced-motion. Default: off
loop, // Replay when finished, default: off
muted, // default: on if autoPlay is on
playsInline = true, // Allows autoplay inline on iOS instead of forcing full-screen playback
@@ -23,6 +23,7 @@ const Video = ({
credit, // optional source/credit line
}) => {
const [videoInfo, setVideoInfo] = useState(null);
+ const videoRef = useRef(null);
const reportId = useReportId();
const countersBase = useBaseUrl('counters/');
const assetsBase = useBaseUrl('/');
@@ -31,6 +32,11 @@ const Video = ({
const resolveAsset = (p) => (p ? `${assetsBase}${String(p).replace(/^\//, '')}` : undefined);
+ const resolvedAutoPlay = autoPlay ?? false;
+ const resolvedLoop = loop ?? false;
+ const resolvedControls = controls ?? true;
+ const resolvedMuted = muted ?? (resolvedAutoPlay ? true : false);
+
useEffect(() => {
if (!reportId) return;
@@ -60,12 +66,42 @@ const Video = ({
loadCounters();
}, [reportId, videoKey, countersBase]);
- if (!videoInfo) return Loading... ;
+ // Managed autoplay: play only while the video is in the viewport, pause when it
+ // leaves, and never autoplay for users who prefer reduced motion. We drive this
+ // from JS (rather than the native `autoplay` attribute) so the behavior is
+ // consistent across browsers and satisfies WCAG 2.2.2. `videoInfo` is a
+ // dependency because the element isn't rendered until counters load.
+ useEffect(() => {
+ const el = videoRef.current;
+ if (!el || !resolvedAutoPlay || typeof window === 'undefined') return;
- const resolvedAutoPlay = autoPlay ?? false;
- const resolvedLoop = loop ?? false;
- const resolvedControls = controls ?? true;
- const resolvedMuted = muted ?? (resolvedAutoPlay ? true : false);
+ const motionQuery = window.matchMedia ? window.matchMedia('(prefers-reduced-motion: reduce)') : null;
+ const prefersReduced = () => !!(motionQuery && motionQuery.matches);
+
+ // Older browsers without IntersectionObserver: best-effort play unless reduced motion.
+ if (typeof IntersectionObserver === 'undefined') {
+ if (!prefersReduced()) el.play?.().catch(() => {});
+ return;
+ }
+
+ const observer = new IntersectionObserver(
+ (entries) => {
+ for (const entry of entries) {
+ if (entry.isIntersecting && !prefersReduced()) {
+ el.play?.().catch(() => {});
+ } else {
+ el.pause?.();
+ }
+ }
+ },
+ { threshold: 0.25 }
+ );
+
+ observer.observe(el);
+ return () => observer.disconnect();
+ }, [resolvedAutoPlay, videoInfo]);
+
+ if (!videoInfo) return Loading... ;
return (