Pintura Video Editor Server Configuration
The video editor extension isn't included in the Pintura image editor product package, it's available as an upgrade on the pricing page
Please note that client-side video encoding is useful for encoding short videos, it's advised to use server side encoding for content longer than a couple minutes.
The Redact, Frame, and Fill util are currently not supported when using the video extension.
When using createFFmpegEncoder
, createMediaStreamEncoder
, or the createMuxerEncoder
encoder you can encode video on the client. But as described in the documentation every approach has their downsides.
Instead we can also run the commands used by createFFmpegEncoder
on the server, we can see how to use the imageState
object and generate the needed commands below.
Please note that this script isn't included in the Pintura Video Extension package and only serves as a suggestion on how you could configure FFmpeg on your server. It also currently doesn't include rendering annotations.
Support doesn't cover setting up FFmpeg on your server.
To upload only the instructions we can use a custom imageWriter.
// custom upload writer
const createUploadWriter = () => [
[
// task
async (state, options, onprogress) => {
console.log('my-upload-task', state, options, onprogress);
// upload instructions to your server here
// - state.src => src file
// - state.imageState => current state
return {
...state,
};
},
// name of task
'upload',
],
];
// set as imageWriter
const editor = openDefaultEditor({
src: './my-video.mp4',
imageWriter: createUploadWriter(),
});
Let's take a look at the script.
- We push all the base arguments to the
args
array. - We push all the filters we'll use to the
filters
array. - The
size
variable contains the video size{ width, height }
- The
duration
variable contains the duration of the video in seconds. - The
targetSize
variable contains the output size{ width, height, fit, upscale }
// The base argument chain
const args = [];
// The FFmpeg filters we'll apply
const filters = [];
// Set input file to source file
args.push('-i', 'src.mp4');
// Get references to imageState props
const {
flipX,
flipY,
rotation,
crop,
trim,
gamma,
colorMatrix,
convolutionMatrix,
} = imageState;
// 1. Flip
if (flipX || flipY) {
flipX && filters.push(`hflip`);
flipY && filters.push(`vflip`);
}
// 2. Rotate
if (rotation) {
const width =
Math.abs(size.width * Math.sin(rotation)) +
Math.abs(size.height * Math.cos(rotation));
const height =
Math.abs(size.width * Math.cos(rotation)) +
Math.abs(size.height * Math.sin(rotation));
filters.push(
`rotate='${rotation}:ow=${Math.floor(width)}:oh=${Math.floor(height)}'`
);
}
// 3. Crop
if (
!(
crop.x === 0 &&
crop.y === 0 &&
crop.width === size.width &&
crop.height === size.height
)
) {
filters.push(`crop=${crop.width}:${crop.height}:${crop.x}:${crop.y}`);
}
// 4. Resize
if (targetSize) {
const { fit = 'contain', upscale = false } = targetSize;
let { width, height } = targetSize;
width = width % 2 === 0 ? width : width + 1;
height = height % 2 === 0 ? height : height + 1;
// limit size if is not allowed to upscale
if (!upscale) {
const scalar = Math.min(
(width || Number.MAX_SAFE_INTEGER) / crop.width,
(height || Number.MAX_SAFE_INTEGER) / crop.height
);
if (scalar > 1) {
if (width) width /= scalar;
if (height) height /= scalar;
}
}
if (width && !height) filters.push(`scale=${width}:trunc(ow/a/2)*2`);
else if (height && !width) filters.push(`scale=trunc(oh/a/2)*2:${height}`);
else if (width && height) {
// if is contain, scale down
if (fit === 'contain') {
filters.push(
`scale=${width}:${height}:force_original_aspect_ratio=decrease`
);
}
// if is cover, scale up
else if (fit === 'cover') {
filters.push(
`scale=${width}:${height}:force_original_aspect_ratio=increase,setsar=1`
);
}
// ignore aspect ratio
else if (fit === 'force') {
filters.push(`scale=${width}:${height},setsar=1`);
}
}
}
// 5. Convolution Matrix
if (convolutionMatrix && convolutionMatrix.clarity) {
filters.push(`convolution='${convolutionMatrix.clarity.join(' ')}'`);
}
// 6. Gamma
if (gamma > 0) {
filters.push(`eq=gamma=${gamma}:gamma_weight=0.85`);
}
// 7. Color Matrix
const colorMatrices = Object.values(colorMatrix || {}).filter(Boolean);
if (colorMatrices.length) {
// See helper section below for the getColorMatrixFromColorMatrices function definition
const colorMatrix = getColorMatrixFromColorMatrices(colorMatrices);
const skip = [4, 9, 14, 19];
const cl = colorMatrix;
const ccm = colorMatrix.filter((v, i) => !skip.includes(i));
const [ro, go, bo] = [cl[4] + cl[3], cl[9] + cl[8], cl[14] + cl[13]];
filters.push(
`colorchannelmixer=${ccm.join(':')}`,
`lutrgb=r=val+(${ro * 255}):g=val+(${go * 255}):b=val+(${bo * 255})`
);
}
// 8. Trim
if (trim) {
const inputRanges = (
Array.isArray(trim) && isNumber(trim[0]) ? [trim] : trim
)
.map((range, index) => {
const from = range[0] * duration;
const to = range[1] * duration;
const v = `[0:v]trim=start=${from}:end=${to},setpts=PTS-STARTPTS${filters
.map((filter) => ',' + filter)
.join('')}[${index}v];`;
const a = `[0:a]atrim=start=${from}:end=${to},asetpts=PTS-STARTPTS[${index}a];`;
return v + a;
})
.join('');
// Filters have now been moved to trim instructions
filters.length = 0;
const inputRangesKeys = trim
.map((_, index) => `[${index}v][${index}a]`)
.join('');
const concatOutput = `${inputRangesKeys}concat=n=${trim.length}:v=1:a=1[outv][outa]`;
args.push('-filter_complex', `${inputRanges}${concatOutput}`);
args.push('-map', '[outv]', '-map', '[outa]');
} else {
// add ffmpeg filters command
filters.length && args.push('-filter_complex', `${filters.join(',')}`);
}
// Add output file
args.push('dest.mp4');
// The `args` array now contains the list of commands to pass to FFmpeg CLI
console.log(args);
Color Matrix multiplication helper functions.
function getColorMatrixFromColorMatrices(colorMatrices) {
return colorMatrices.length
? colorMatrices.reduce(
(previous, current) => dotColorMatrix([...previous], current),
colorMatrices.shift()
)
: [];
}
function dotColorMatrix(a, b) {
const res = new Array(20);
// R
res[0] = a[0] * b[0] + a[1] * b[5] + a[2] * b[10] + a[3] * b[15];
res[1] = a[0] * b[1] + a[1] * b[6] + a[2] * b[11] + a[3] * b[16];
res[2] = a[0] * b[2] + a[1] * b[7] + a[2] * b[12] + a[3] * b[17];
res[3] = a[0] * b[3] + a[1] * b[8] + a[2] * b[13] + a[3] * b[18];
res[4] = a[0] * b[4] + a[1] * b[9] + a[2] * b[14] + a[3] * b[19] + a[4];
// G
res[5] = a[5] * b[0] + a[6] * b[5] + a[7] * b[10] + a[8] * b[15];
res[6] = a[5] * b[1] + a[6] * b[6] + a[7] * b[11] + a[8] * b[16];
res[7] = a[5] * b[2] + a[6] * b[7] + a[7] * b[12] + a[8] * b[17];
res[8] = a[5] * b[3] + a[6] * b[8] + a[7] * b[13] + a[8] * b[18];
res[9] = a[5] * b[4] + a[6] * b[9] + a[7] * b[14] + a[8] * b[19] + a[9];
// B
res[10] = a[10] * b[0] + a[11] * b[5] + a[12] * b[10] + a[13] * b[15];
res[11] = a[10] * b[1] + a[11] * b[6] + a[12] * b[11] + a[13] * b[16];
res[12] = a[10] * b[2] + a[11] * b[7] + a[12] * b[12] + a[13] * b[17];
res[13] = a[10] * b[3] + a[11] * b[8] + a[12] * b[13] + a[13] * b[18];
res[14] =
a[10] * b[4] + a[11] * b[9] + a[12] * b[14] + a[13] * b[19] + a[14];
// A
res[15] = a[15] * b[0] + a[16] * b[5] + a[17] * b[10] + a[18] * b[15];
res[16] = a[15] * b[1] + a[16] * b[6] + a[17] * b[11] + a[18] * b[16];
res[17] = a[15] * b[2] + a[16] * b[7] + a[17] * b[12] + a[18] * b[17];
res[18] = a[15] * b[3] + a[16] * b[8] + a[17] * b[13] + a[18] * b[18];
res[19] =
a[15] * b[4] + a[16] * b[9] + a[17] * b[14] + a[18] * b[19] + a[19];
return res;
}