Skip to content

Commit

Permalink
🎬 Add video conversions for avi, mov -> mp4 (#1696)
Browse files Browse the repository at this point in the history
  • Loading branch information
fwkoch authored Dec 10, 2024
1 parent 34f0f9e commit 416fc41
Show file tree
Hide file tree
Showing 7 changed files with 115 additions and 8 deletions.
5 changes: 5 additions & 0 deletions .changeset/few-lemons-dream.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'myst-cli': patch
---

Add support for avi -> mp4
5 changes: 5 additions & 0 deletions .changeset/red-planes-marry.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'myst-cli': patch
---

Add mov -> mp4 conversion with ffmpeg
6 changes: 3 additions & 3 deletions docs/figures.md
Original file line number Diff line number Diff line change
Expand Up @@ -176,7 +176,7 @@ For example, when exporting to $\LaTeX$ the best format is a `.pdf` if it is ava

## Videos

To embed a video you can either use a video platforms embed script or directly embed an `mp4` video file. For example, the
To embed a video you can either use a video platforms embed script or directly embed an `mp4` video file. For example:

```markdown
:::{figure} ./videos/links.mp4
Expand All @@ -188,13 +188,13 @@ or
![](./videos/links.mp4)
```

Will copy the video to your static files and embed a video in your HTML output.
will copy the video to your static files and embed a video in your HTML output.

:::{figure} ./videos/links.mp4
An embedded video with a caption!
:::

These videos can also be used in the [image](#image-directive) or even in simple [Markdown image](#md:image).
If you have [ffmpeg](https://www.ffmpeg.org/) installed, you may also include `.mov` and `.avi` video files, and MyST will convert them to `.mp4` and include them. Videos can also be used in the [image](#image-directive) or even in simple [Markdown image](#md:image).

### Use an image in place of a video for static exports

Expand Down
38 changes: 33 additions & 5 deletions packages/myst-cli/src/transforms/images.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,12 @@ import { castSession } from '../session/cache.js';
import { watch } from '../store/index.js';
import { EXT_REQUEST_HEADERS } from '../utils/headers.js';
import { addWarningForFile } from '../utils/addWarningForFile.js';
import { ImageExtensions, KNOWN_IMAGE_EXTENSIONS } from '../utils/resolveExtension.js';
import { imagemagick, inkscape } from '../utils/index.js';
import {
ImageExtensions,
KNOWN_IMAGE_EXTENSIONS,
KNOWN_VIDEO_EXTENSIONS,
} from '../utils/resolveExtension.js';
import { ffmpeg, imagemagick, inkscape } from '../utils/index.js';

export const BASE64_HEADER_SPLIT = ';base64,';

Expand Down Expand Up @@ -245,6 +249,7 @@ type ConversionOpts = {
inkscapeAvailable: boolean;
imagemagickAvailable: boolean;
dwebpAvailable: boolean;
ffmpegAvailable: boolean;
};

type ConversionFn = (
Expand All @@ -258,14 +263,27 @@ type ConversionFn = (
* Factory function for all simple imagemagick conversions
*/
function imagemagickConvert(
to: ImageExtensions,
from: ImageExtensions,
to: ImageExtensions,
options?: { trim?: boolean },
) {
return async (session: ISession, source: string, writeFolder: string, opts: ConversionOpts) => {
const { imagemagickAvailable } = opts;
if (imagemagickAvailable) {
return imagemagick.convert(to, from, session, source, writeFolder, options);
return imagemagick.convert(from, to, session, source, writeFolder, options);
}
return null;
};
}

/**
* Factory function for all simple ffmpeg conversions
*/
function ffmpegConvert(from: ImageExtensions, to: ImageExtensions) {
return async (session: ISession, source: string, writeFolder: string, opts: ConversionOpts) => {
const { ffmpegAvailable } = opts;
if (ffmpegAvailable) {
return ffmpeg.convert(from, to, session, source, writeFolder);
}
return null;
};
Expand Down Expand Up @@ -386,6 +404,12 @@ const conversionFnLookup: Record<string, Record<string, ConversionFn>> = {
[ImageExtensions.tif]: {
[ImageExtensions.png]: imagemagickConvert(ImageExtensions.tif, ImageExtensions.png),
},
[ImageExtensions.mov]: {
[ImageExtensions.mp4]: ffmpegConvert(ImageExtensions.mov, ImageExtensions.mp4),
},
[ImageExtensions.avi]: {
[ImageExtensions.mp4]: ffmpegConvert(ImageExtensions.avi, ImageExtensions.mp4),
},
};

/**
Expand Down Expand Up @@ -441,6 +465,7 @@ export async function transformImageFormats(
const inkscapeAvailable = inkscape.isInkscapeAvailable();
const imagemagickAvailable = imagemagick.isImageMagickAvailable();
const dwebpAvailable = imagemagick.isDwebpAvailable();
const ffmpegAvailable = ffmpeg.isFfmpegAvailable();

/**
* convert runs the input conversion functions on the image
Expand All @@ -459,6 +484,7 @@ export async function transformImageFormats(
inkscapeAvailable,
imagemagickAvailable,
dwebpAvailable,
ffmpegAvailable,
});
}
}
Expand Down Expand Up @@ -539,7 +565,9 @@ export async function transformThumbnail(
}
if (!thumbnail && mdast) {
// The thumbnail isn't found, grab it from the mdast, excluding videos
const [image] = (selectAll('image', mdast) as Image[]).filter((n) => !n.url.endsWith('.mp4'));
const [image] = (selectAll('image', mdast) as Image[]).filter((n) => {
return !KNOWN_VIDEO_EXTENSIONS.find((ext) => n.url.endsWith(ext));
});
if (!image) {
session.log.debug(`${file}#frontmatter.thumbnail is not set, and there are no images.`);
return;
Expand Down
64 changes: 64 additions & 0 deletions packages/myst-cli/src/utils/ffmpeg.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import fs from 'node:fs';
import path from 'node:path';
import which from 'which';
import type { LoggerDE } from 'myst-cli-utils';
import { makeExecutable } from 'myst-cli-utils';
import { RuleId } from 'myst-common';
import type { ISession } from '../session/types.js';
import { addWarningForFile } from './addWarningForFile.js';

function createFfmpegLogger(session: ISession): LoggerDE {
const logger = {
debug(data: string) {
const line = data.trim();
session.log.debug(line);
},
error(data: string) {
const line = data.trim();
if (!line) return;
// All ffmpeg logging comes through as errors, convert all to debug
session.log.debug(data);
},
};
return logger;
}

export function isFfmpegAvailable(): boolean {
return !!which.sync('ffmpeg', { nothrow: true });
}

export async function convert(
inputExtension: string,
outputExtension: string,
session: ISession,
input: string,
writeFolder: string,
) {
if (!fs.existsSync(input)) return null;
const { name, ext } = path.parse(input);
if (ext !== inputExtension) return null;
const filename = `${name}${outputExtension}`;
const output = path.join(writeFolder, filename);
const inputFormatUpper = inputExtension.slice(1).toUpperCase();
const outputFormat = outputExtension.slice(1);
if (fs.existsSync(output)) {
session.log.debug(`Cached file found for converted ${inputFormatUpper}: ${input}`);
} else {
const ffmpegCommand = `ffmpeg -i ${input} -crf 18 -vf "crop=trunc(iw/2)*2:trunc(ih/2)*2" ${output}`;
session.log.debug(`Executing: ${ffmpegCommand}`);
const exec = makeExecutable(ffmpegCommand, createFfmpegLogger(session));
try {
await exec();
} catch (err) {
addWarningForFile(
session,
input,
`Could not convert from ${inputFormatUpper} to ${outputFormat.toUpperCase()} - ${err}`,
'error',
{ ruleId: RuleId.imageFormatConverts },
);
return null;
}
}
return filename;
}
1 change: 1 addition & 0 deletions packages/myst-cli/src/utils/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,5 +17,6 @@ export * from './uniqueArray.js';
export * from './github.js';
export * from './whiteLabelling.js';

export * as ffmpeg from './ffmpeg.js';
export * as imagemagick from './imagemagick.js';
export * as inkscape from './inkscape.js';
4 changes: 4 additions & 0 deletions packages/myst-cli/src/utils/resolveExtension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,12 @@ export enum ImageExtensions {
eps = '.eps',
webp = '.webp',
mp4 = '.mp4', // A moving image!
mov = '.mov',
avi = '.avi',
}

export const KNOWN_IMAGE_EXTENSIONS = Object.values(ImageExtensions);
export const KNOWN_VIDEO_EXTENSIONS = ['.mp4', '.mov', '.avi'];

export const VALID_FILE_EXTENSIONS = ['.md', '.ipynb', '.tex', '.myst.json'];
export const KNOWN_FAST_BUILDS = new Set(['.ipynb', '.md', '.tex', '.myst.json']);
Expand Down

0 comments on commit 416fc41

Please sign in to comment.