From 416fc4114565825529d159b823d967fd5e5849ba Mon Sep 17 00:00:00 2001 From: Franklin Koch Date: Mon, 9 Dec 2024 19:45:14 -0700 Subject: [PATCH] =?UTF-8?q?=F0=9F=8E=AC=20Add=20video=20conversions=20for?= =?UTF-8?q?=20avi,=20mov=20->=20mp4=20(#1696)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .changeset/few-lemons-dream.md | 5 ++ .changeset/red-planes-marry.md | 5 ++ docs/figures.md | 6 +- packages/myst-cli/src/transforms/images.ts | 38 +++++++++-- packages/myst-cli/src/utils/ffmpeg.ts | 64 +++++++++++++++++++ packages/myst-cli/src/utils/index.ts | 1 + .../myst-cli/src/utils/resolveExtension.ts | 4 ++ 7 files changed, 115 insertions(+), 8 deletions(-) create mode 100644 .changeset/few-lemons-dream.md create mode 100644 .changeset/red-planes-marry.md create mode 100644 packages/myst-cli/src/utils/ffmpeg.ts diff --git a/.changeset/few-lemons-dream.md b/.changeset/few-lemons-dream.md new file mode 100644 index 000000000..939f1d172 --- /dev/null +++ b/.changeset/few-lemons-dream.md @@ -0,0 +1,5 @@ +--- +'myst-cli': patch +--- + +Add support for avi -> mp4 diff --git a/.changeset/red-planes-marry.md b/.changeset/red-planes-marry.md new file mode 100644 index 000000000..6164263d3 --- /dev/null +++ b/.changeset/red-planes-marry.md @@ -0,0 +1,5 @@ +--- +'myst-cli': patch +--- + +Add mov -> mp4 conversion with ffmpeg diff --git a/docs/figures.md b/docs/figures.md index 1ce376901..1361bd61d 100644 --- a/docs/figures.md +++ b/docs/figures.md @@ -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 @@ -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 diff --git a/packages/myst-cli/src/transforms/images.ts b/packages/myst-cli/src/transforms/images.ts index ad98e5fd1..f91382090 100644 --- a/packages/myst-cli/src/transforms/images.ts +++ b/packages/myst-cli/src/transforms/images.ts @@ -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,'; @@ -245,6 +249,7 @@ type ConversionOpts = { inkscapeAvailable: boolean; imagemagickAvailable: boolean; dwebpAvailable: boolean; + ffmpegAvailable: boolean; }; type ConversionFn = ( @@ -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; }; @@ -386,6 +404,12 @@ const conversionFnLookup: Record> = { [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), + }, }; /** @@ -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 @@ -459,6 +484,7 @@ export async function transformImageFormats( inkscapeAvailable, imagemagickAvailable, dwebpAvailable, + ffmpegAvailable, }); } } @@ -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; diff --git a/packages/myst-cli/src/utils/ffmpeg.ts b/packages/myst-cli/src/utils/ffmpeg.ts new file mode 100644 index 000000000..3a7649f1a --- /dev/null +++ b/packages/myst-cli/src/utils/ffmpeg.ts @@ -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; +} diff --git a/packages/myst-cli/src/utils/index.ts b/packages/myst-cli/src/utils/index.ts index 0894edd6b..009648f00 100644 --- a/packages/myst-cli/src/utils/index.ts +++ b/packages/myst-cli/src/utils/index.ts @@ -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'; diff --git a/packages/myst-cli/src/utils/resolveExtension.ts b/packages/myst-cli/src/utils/resolveExtension.ts index c35ffdca6..ffe74a87e 100644 --- a/packages/myst-cli/src/utils/resolveExtension.ts +++ b/packages/myst-cli/src/utils/resolveExtension.ts @@ -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']);