From 13c67a78d4b85f735547b76973eedb190ac90586 Mon Sep 17 00:00:00 2001 From: Adam Gohs Date: Thu, 25 Jun 2026 14:09:45 -0400 Subject: [PATCH 1/2] Add scroll-into-view autoplay to Video and video compression tooling - Video: IntersectionObserver autoplay + prefers-reduced-motion guard (WCAG 2.2.2), preserving main's useBaseUrl asset resolution - Add compress-video npm script (H.264/CRF via ffmpeg-static); ignore *-raw.* originals --- .gitignore | 6 + package-lock.json | 134 ++++++++++++++++++++ package.json | 2 + scripts/compress-video.js | 249 ++++++++++++++++++++++++++++++++++++++ src/components/Video.js | 53 ++++++-- 5 files changed, 436 insertions(+), 8 deletions(-) create mode 100644 scripts/compress-video.js 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/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