From 3e55e4b66b73d982ceac4620f504a1fcf0827488 Mon Sep 17 00:00:00 2001 From: Satyajit Sahoo Date: Tue, 25 Nov 2025 18:09:09 +0100 Subject: [PATCH] Preserve video aspect ratio with ffprobe --- docusaurus.config.js | 2 + package.json | 1 + src/plugins/rehype-video-aspect-ratio.mjs | 170 ++++++++++++++++++ .../version-7.x/use-prevent-remove.md | 1 - yarn.lock | 90 ++++++++++ 5 files changed, 263 insertions(+), 1 deletion(-) create mode 100644 src/plugins/rehype-video-aspect-ratio.mjs diff --git a/docusaurus.config.js b/docusaurus.config.js index 4567b878ac..91404170e7 100755 --- a/docusaurus.config.js +++ b/docusaurus.config.js @@ -1,6 +1,7 @@ import remarkNpm2Yarn from '@docusaurus/remark-plugin-npm2yarn'; import rehypeCodeblockMeta from './src/plugins/rehype-codeblock-meta.mjs'; import rehypeStaticToDynamic from './src/plugins/rehype-static-to-dynamic.mjs'; +import rehypeVideoAspectRatio from './src/plugins/rehype-video-aspect-ratio.mjs'; export default { future: { @@ -149,6 +150,7 @@ export default { rehypeCodeblockMeta, { match: { snack: true, lang: true, tabs: true } }, ], + [rehypeVideoAspectRatio, { staticDir: 'static' }], rehypeStaticToDynamic, ], }, diff --git a/package.json b/package.json index c2911a3a88..dc1b26442d 100755 --- a/package.json +++ b/package.json @@ -32,6 +32,7 @@ }, "devDependencies": { "@babel/types": "^7.28.5", + "@ffprobe-installer/ffprobe": "^2.1.2", "markdownlint": "^0.36.1", "markdownlint-cli2": "^0.14.0", "prettier": "^3.6.2", diff --git a/src/plugins/rehype-video-aspect-ratio.mjs b/src/plugins/rehype-video-aspect-ratio.mjs new file mode 100644 index 0000000000..c694e55484 --- /dev/null +++ b/src/plugins/rehype-video-aspect-ratio.mjs @@ -0,0 +1,170 @@ +import ffprobe from '@ffprobe-installer/ffprobe'; +import { exec } from 'child_process'; +import fs from 'fs'; +import path from 'path'; +import { visit } from 'unist-util-visit'; +import { promisify } from 'util'; + +const execAsync = promisify(exec); + +/** + * Rehype plugin to add aspect ratio preservation to video tags + */ +export default function rehypeVideoAspectRatio({ staticDir }) { + return async (tree, file) => { + const promises = []; + + visit(tree, 'mdxJsxFlowElement', (node) => { + if (node.name === 'video') { + // Find video source - check src attribute or source children + let videoSrc = null; + + // Look for src in attributes + if (node.attributes) { + const srcAttr = node.attributes.find( + (attr) => attr.type === 'mdxJsxAttribute' && attr.name === 'src' + ); + + if (srcAttr) { + videoSrc = srcAttr.value; + } + } + + // If no src attribute, look for source children + if (!videoSrc && node.children) { + const sourceNode = node.children.find( + (child) => + child.type === 'mdxJsxFlowElement' && child.name === 'source' + ); + + if (sourceNode?.attributes) { + const srcAttr = sourceNode.attributes.find( + (attr) => attr.type === 'mdxJsxAttribute' && attr.name === 'src' + ); + + if (srcAttr) { + videoSrc = srcAttr.value; + } + } + } + + const isLocalFile = + videoSrc && + !videoSrc.startsWith('http://') && + !videoSrc.startsWith('https://') && + !videoSrc.startsWith('//'); + + if (isLocalFile) { + const videoPath = path.join( + videoSrc.startsWith('/') ? file.cwd : file.dirname, + staticDir, + videoSrc + ); + + if (fs.existsSync(videoPath)) { + const promise = getVideoDimensions(videoPath).then((dimensions) => { + if (dimensions.width && dimensions.height) { + applyAspectRatio(node, dimensions.width, dimensions.height); + } + }); + + promises.push(promise); + } else { + throw new Error(`Video file does not exist (got ${videoPath})`); + } + } + } + }); + + await Promise.all(promises); + }; +} + +/** + * Apply aspect ratio styles to a video node + */ +function applyAspectRatio(node, width, height) { + const data = { + estree: { + type: 'Program', + body: [ + { + type: 'ExpressionStatement', + expression: { + type: 'ObjectExpression', + properties: [ + { + type: 'Property', + key: { type: 'Identifier', name: 'aspectRatio' }, + value: { type: 'Literal', value: width / height }, + kind: 'init', + }, + ], + }, + }, + ], + }, + }; + + node.attributes = node.attributes || []; + + let styleAttr = node.attributes?.find( + (attr) => attr.type === 'mdxJsxAttribute' && attr.name === 'style' + ); + + if (styleAttr) { + const properties = + styleAttr.value?.data?.estree?.body?.[0]?.expression?.properties ?? []; + + data.estree.body[0].expression.properties.push(...properties); + } + + styleAttr = { + type: 'mdxJsxAttribute', + name: 'style', + value: { + type: 'mdxJsxAttributeValueExpression', + data, + }, + }; + + const existingIndex = node.attributes.findIndex( + (attr) => attr.type === 'mdxJsxAttribute' && attr.name === 'style' + ); + + if (existingIndex !== -1) { + node.attributes[existingIndex] = styleAttr; + } else { + node.attributes.push(styleAttr); + } +} + +/** + * Get video dimensions using ffprobe + */ +async function getVideoDimensions(filePath) { + const { stdout } = await execAsync( + `${ffprobe.path} -v error -of flat=s=_ -select_streams v:0 -show_entries stream=height,width "${filePath}"` + ); + + const lines = stdout.trim().split('\n'); + const dimensions = {}; + + for (const line of lines) { + if (line.includes('width')) { + const width = Number(line.split('=')[1]); + + if (Number.isFinite(width) && width > 0) { + dimensions.width = width; + } + } else if (line.includes('height')) { + const height = Number(line.split('=')[1]); + + if (Number.isFinite(height) && height > 0) { + dimensions.height = height; + } + } + } + + return dimensions; +} diff --git a/versioned_docs/version-7.x/use-prevent-remove.md b/versioned_docs/version-7.x/use-prevent-remove.md index 23c5591c57..a37168eb9c 100644 --- a/versioned_docs/version-7.x/use-prevent-remove.md +++ b/versioned_docs/version-7.x/use-prevent-remove.md @@ -156,4 +156,3 @@ Doing so has several benefits: - This approach still works if the app is closed or crashes unexpectedly. - It's less intrusive to the user as they can still navigate away from the screen to check something and return without losing the data. -``` diff --git a/yarn.lock b/yarn.lock index bc4e5a7152..3259bd9475 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2202,6 +2202,95 @@ __metadata: languageName: node linkType: hard +"@ffprobe-installer/darwin-arm64@npm:5.0.1": + version: 5.0.1 + resolution: "@ffprobe-installer/darwin-arm64@npm:5.0.1" + conditions: os=darwin & cpu=arm64 + languageName: node + linkType: hard + +"@ffprobe-installer/darwin-x64@npm:5.1.0": + version: 5.1.0 + resolution: "@ffprobe-installer/darwin-x64@npm:5.1.0" + conditions: os=darwin & cpu=x64 + languageName: node + linkType: hard + +"@ffprobe-installer/ffprobe@npm:^2.1.2": + version: 2.1.2 + resolution: "@ffprobe-installer/ffprobe@npm:2.1.2" + dependencies: + "@ffprobe-installer/darwin-arm64": "npm:5.0.1" + "@ffprobe-installer/darwin-x64": "npm:5.1.0" + "@ffprobe-installer/linux-arm": "npm:5.2.0" + "@ffprobe-installer/linux-arm64": "npm:5.2.0" + "@ffprobe-installer/linux-ia32": "npm:5.2.0" + "@ffprobe-installer/linux-x64": "npm:5.2.0" + "@ffprobe-installer/win32-ia32": "npm:5.1.0" + "@ffprobe-installer/win32-x64": "npm:5.1.0" + dependenciesMeta: + "@ffprobe-installer/darwin-arm64": + optional: true + "@ffprobe-installer/darwin-x64": + optional: true + "@ffprobe-installer/linux-arm": + optional: true + "@ffprobe-installer/linux-arm64": + optional: true + "@ffprobe-installer/linux-ia32": + optional: true + "@ffprobe-installer/linux-x64": + optional: true + "@ffprobe-installer/win32-ia32": + optional: true + "@ffprobe-installer/win32-x64": + optional: true + checksum: d3a0e472c9a31bffe10facf6ee0ce4520b4df13d1cf3fdcf7e6ead367f895a348633481c6010c0d9f30a950fe89f791aade9755360047c8dd48d098dccd8414b + languageName: node + linkType: hard + +"@ffprobe-installer/linux-arm64@npm:5.2.0": + version: 5.2.0 + resolution: "@ffprobe-installer/linux-arm64@npm:5.2.0" + conditions: os=linux & cpu=arm64 + languageName: node + linkType: hard + +"@ffprobe-installer/linux-arm@npm:5.2.0": + version: 5.2.0 + resolution: "@ffprobe-installer/linux-arm@npm:5.2.0" + conditions: os=linux & cpu=arm + languageName: node + linkType: hard + +"@ffprobe-installer/linux-ia32@npm:5.2.0": + version: 5.2.0 + resolution: "@ffprobe-installer/linux-ia32@npm:5.2.0" + conditions: os=linux & cpu=ia32 + languageName: node + linkType: hard + +"@ffprobe-installer/linux-x64@npm:5.2.0": + version: 5.2.0 + resolution: "@ffprobe-installer/linux-x64@npm:5.2.0" + conditions: os=linux & cpu=x64 + languageName: node + linkType: hard + +"@ffprobe-installer/win32-ia32@npm:5.1.0": + version: 5.1.0 + resolution: "@ffprobe-installer/win32-ia32@npm:5.1.0" + conditions: os=win32 & cpu=ia32 + languageName: node + linkType: hard + +"@ffprobe-installer/win32-x64@npm:5.1.0": + version: 5.1.0 + resolution: "@ffprobe-installer/win32-x64@npm:5.1.0" + conditions: os=win32 & cpu=x64 + languageName: node + linkType: hard + "@hapi/hoek@npm:^9.0.0": version: 9.3.0 resolution: "@hapi/hoek@npm:9.3.0" @@ -10517,6 +10606,7 @@ __metadata: "@docusaurus/plugin-google-analytics": "npm:3.6.1" "@docusaurus/preset-classic": "npm:3.6.1" "@docusaurus/remark-plugin-npm2yarn": "npm:3.6.1" + "@ffprobe-installer/ffprobe": "npm:^2.1.2" "@octokit/graphql": "npm:^7.1.0" "@react-navigation/core": "npm:^7.0.4" escape-html: "npm:^1.0.3"