Skip to content

Commit 9fad2a5

Browse files
committed
Preserve video aspect ratio with ffprobe
1 parent 3e85069 commit 9fad2a5

File tree

4 files changed

+174
-1
lines changed

4 files changed

+174
-1
lines changed

.github/workflows/ci.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,9 @@ jobs:
1111
- name: Checkout
1212
uses: actions/checkout@v3
1313

14+
- name: Setup FFmpeg
15+
uses: AnimMouse/setup-ffmpeg@v1
16+
1417
- name: Setup
1518
uses: ./.github/actions/setup
1619

docusaurus.config.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import remarkNpm2Yarn from '@docusaurus/remark-plugin-npm2yarn';
22
import rehypeCodeblockMeta from './src/plugins/rehype-codeblock-meta.mjs';
33
import rehypeStaticToDynamic from './src/plugins/rehype-static-to-dynamic.mjs';
4+
import rehypeVideoAspectRatio from './src/plugins/rehype-video-aspect-ratio.mjs';
45

56
export default {
67
future: {
@@ -149,6 +150,7 @@ export default {
149150
rehypeCodeblockMeta,
150151
{ match: { snack: true, lang: true, tabs: true } },
151152
],
153+
[rehypeVideoAspectRatio, { staticDir: 'static' }],
152154
rehypeStaticToDynamic,
153155
],
154156
},
Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
import { exec } from 'child_process';
2+
import fs from 'fs';
3+
import path from 'path';
4+
import { visit } from 'unist-util-visit';
5+
import { promisify } from 'util';
6+
7+
const execAsync = promisify(exec);
8+
9+
/**
10+
* Rehype plugin to add aspect ratio preservation to video tags
11+
*/
12+
export default function rehypeVideoAspectRatio({ staticDir }) {
13+
return async (tree, file) => {
14+
const promises = [];
15+
16+
visit(tree, 'mdxJsxFlowElement', (node) => {
17+
if (node.name === 'video') {
18+
// Find video source - check src attribute or source children
19+
let videoSrc = null;
20+
21+
// Look for src in attributes
22+
if (node.attributes) {
23+
const srcAttr = node.attributes.find(
24+
(attr) => attr.type === 'mdxJsxAttribute' && attr.name === 'src'
25+
);
26+
27+
if (srcAttr) {
28+
videoSrc = srcAttr.value;
29+
}
30+
}
31+
32+
// If no src attribute, look for source children
33+
if (!videoSrc && node.children) {
34+
const sourceNode = node.children.find(
35+
(child) =>
36+
child.type === 'mdxJsxFlowElement' && child.name === 'source'
37+
);
38+
39+
if (sourceNode?.attributes) {
40+
const srcAttr = sourceNode.attributes.find(
41+
(attr) => attr.type === 'mdxJsxAttribute' && attr.name === 'src'
42+
);
43+
44+
if (srcAttr) {
45+
videoSrc = srcAttr.value;
46+
}
47+
}
48+
}
49+
50+
const isLocalFile =
51+
videoSrc &&
52+
!videoSrc.startsWith('http://') &&
53+
!videoSrc.startsWith('https://') &&
54+
!videoSrc.startsWith('//');
55+
56+
if (isLocalFile) {
57+
const videoPath = path.join(
58+
videoSrc.startsWith('/') ? file.cwd : file.dirname,
59+
staticDir,
60+
videoSrc
61+
);
62+
63+
if (fs.existsSync(videoPath)) {
64+
const promise = getVideoDimensions(videoPath).then((dimensions) => {
65+
if (dimensions.width && dimensions.height) {
66+
applyAspectRatio(node, dimensions.width, dimensions.height);
67+
}
68+
});
69+
70+
promises.push(promise);
71+
} else {
72+
throw new Error(`Video file does not exist (got ${videoPath})`);
73+
}
74+
}
75+
}
76+
});
77+
78+
await Promise.all(promises);
79+
};
80+
}
81+
82+
/**
83+
* Apply aspect ratio styles to a video node
84+
*/
85+
function applyAspectRatio(node, width, height) {
86+
const data = {
87+
estree: {
88+
type: 'Program',
89+
body: [
90+
{
91+
type: 'ExpressionStatement',
92+
expression: {
93+
type: 'ObjectExpression',
94+
properties: [
95+
{
96+
type: 'Property',
97+
key: { type: 'Identifier', name: 'aspectRatio' },
98+
value: { type: 'Literal', value: width / height },
99+
kind: 'init',
100+
},
101+
],
102+
},
103+
},
104+
],
105+
},
106+
};
107+
108+
node.attributes = node.attributes || [];
109+
110+
let styleAttr = node.attributes?.find(
111+
(attr) => attr.type === 'mdxJsxAttribute' && attr.name === 'style'
112+
);
113+
114+
if (styleAttr) {
115+
const properties =
116+
styleAttr.value?.data?.estree?.body?.[0]?.expression?.properties ?? [];
117+
118+
data.estree.body[0].expression.properties.push(...properties);
119+
}
120+
121+
styleAttr = {
122+
type: 'mdxJsxAttribute',
123+
name: 'style',
124+
value: {
125+
type: 'mdxJsxAttributeValueExpression',
126+
data,
127+
},
128+
};
129+
130+
const existingIndex = node.attributes.findIndex(
131+
(attr) => attr.type === 'mdxJsxAttribute' && attr.name === 'style'
132+
);
133+
134+
if (existingIndex !== -1) {
135+
node.attributes[existingIndex] = styleAttr;
136+
} else {
137+
node.attributes.push(styleAttr);
138+
}
139+
}
140+
141+
/**
142+
* Get video dimensions using ffprobe
143+
*/
144+
async function getVideoDimensions(filePath) {
145+
const { stdout } = await execAsync(
146+
`ffprobe -v error -of flat=s=_ -select_streams v:0 -show_entries stream=height,width "${filePath}"`
147+
);
148+
149+
const lines = stdout.trim().split('\n');
150+
const dimensions = {};
151+
152+
for (const line of lines) {
153+
if (line.includes('width')) {
154+
const width = Number(line.split('=')[1]);
155+
156+
if (Number.isFinite(width) && width > 0) {
157+
dimensions.width = width;
158+
}
159+
} else if (line.includes('height')) {
160+
const height = Number(line.split('=')[1]);
161+
162+
if (Number.isFinite(height) && height > 0) {
163+
dimensions.height = height;
164+
}
165+
}
166+
}
167+
168+
return dimensions;
169+
}

versioned_docs/version-7.x/use-prevent-remove.md

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -156,4 +156,3 @@ Doing so has several benefits:
156156

157157
- This approach still works if the app is closed or crashes unexpectedly.
158158
- 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.
159-
```

0 commit comments

Comments
 (0)