feat: add video crop option

This commit is contained in:
Pablo Cúbico
2025-09-05 07:07:43 -03:00
committed by Pablo Cuadrado
parent 605c258029
commit 904fd8f95e
6 changed files with 323 additions and 12 deletions
+5 -2
View File
@@ -105,6 +105,7 @@ You can set the `video` property in the conversion options to configure the conv
```ts
type ConversionVideoOptions = {
discard?: boolean;
crop?: { left: number; top: number; width: number; height: number };
width?: number;
height?: number;
fit?: 'fill' | 'contain' | 'cover';
@@ -137,14 +138,16 @@ The provided configuration will apply equally to all video tracks of the input.
If you want to get rid of the video track, use `discard: true`.
### Resizing/rotating video
### Cropping/resizing/rotating video
`crop` can be used to extract a rectangular region from the original video before any rotation or resizing is applied. The rectangle is specified using `left`, `top`, `width` and `height` in the coordinate system of the unrotated frame. Areas outside the input frame are filled with black.
The `width`, `height` and `fit` properties control how the video is resized. If only `width` or `height` is provided, the other value is deduced automatically to preserve the video's original aspect ratio. If both are used, `fit` must be set to control the fitting algorithm:
- `'fill'` will stretch the image to fill the entire box, potentially altering aspect ratio.
- `'contain'` will contain the entire image within the box while preserving aspect ratio. This may lead to letterboxing.
- `'cover'` will scale the image until the entire box is filled, while preserving aspect ratio.
`rotation` rotates the video by the specified number of degrees clockwise. This rotation is applied on top of any rotation metadata in the original input file.
`rotation` rotates the video by the specified number of degrees clockwise. This rotation is applied on top of any rotation metadata in the original input file and happens after cropping.
If `width` or `height` is used in conjunction with `rotation`, they control the post-rotation dimensions.
+5 -2
View File
@@ -314,7 +314,7 @@ for await (const sample of keyFrameSamples) {
### `CanvasSink`
While `VideoSampleSink` extracts raw decoded video samples, you can use `CanvasSink` to extract these samples as canvases instead. In doing so, certain operations such as scaling and rotating can also be handled by the sink. The downside is the additional VRAM requirements for the canvases' framebuffers.
While `VideoSampleSink` extracts raw decoded video samples, you can use `CanvasSink` to extract these samples as canvases instead. In doing so, certain operations such as cropping, scaling, and rotating can also be handled by the sink. The downside is the additional VRAM requirements for the canvases' framebuffers.
::: info
This sink yields `HTMLCanvasElement` whenever possible, and falls back to `OffscreenCanvas` otherwise (in Worker contexts, for example).
@@ -330,6 +330,7 @@ const sink = new CanvasSink(videoTrack, options);
Here, `options` has the following type:
```ts
type CanvasSinkOptions = {
crop?: { left: number; top: number; width: number; height: number };
width?: number;
height?: number;
fit?: 'fill' | 'contain' | 'cover';
@@ -337,6 +338,8 @@ type CanvasSinkOptions = {
poolSize?: number;
};
```
- `crop`\
Crops the source frame to the specified rectangle before any rotation or resizing is applied. Portions outside the original frame are filled with black.
- `width`\
The width of the output canvas in pixels. When omitted but `height` is set, the width will be calculated automatically to maintain the original aspect ratio. Otherwise, the width will be set to the original width of the video.
- `height`\
@@ -347,7 +350,7 @@ type CanvasSinkOptions = {
- `'contain'` will contain the entire image within the box while preserving aspect ratio. This may lead to letterboxing.
- `'cover'` will scale the image until the entire box is filled, while preserving aspect ratio.
- `rotation`\
The clockwise rotation by which to rotate the raw video frame. Defaults to the rotation set in the file metadata. Rotation is applied before resizing.
The clockwise rotation by which to rotate the raw video frame. Defaults to the rotation set in the file metadata. Rotation is applied after cropping and before resizing.
- `poolSize`\
See [Canvas pool](#canvas-pool).
+139
View File
@@ -0,0 +1,139 @@
import {
Input,
Output,
WebMOutputFormat,
BufferTarget,
Conversion,
BlobSource,
ALL_FORMATS,
} from 'mediabunny';
const selectMediaButton = document.querySelector(
'#select-file',
) as HTMLButtonElement;
const cropButton = document.querySelector('#crop-button') as HTMLButtonElement;
const fileNameElement = document.querySelector(
'#file-name',
) as HTMLParagraphElement;
let selectedFile: File | null = null;
const horizontalRule = document.querySelector('hr') as HTMLHRElement;
const outputContainer = document.querySelector(
'#output-container',
) as HTMLDivElement;
const errorElement = document.querySelector(
'#error-element',
) as HTMLParagraphElement;
const cropTopInput = document.querySelector('#crop-top') as HTMLInputElement;
const cropLeftInput = document.querySelector('#crop-left') as HTMLInputElement;
const cropWidthInput = document.querySelector(
'#crop-width',
) as HTMLInputElement;
const cropHeightInput = document.querySelector(
'#crop-height',
) as HTMLInputElement;
const cropVideo = async (file: File) => {
fileNameElement.textContent = file.name;
horizontalRule.style.display = '';
errorElement.textContent = '';
outputContainer.innerHTML = '';
try {
const input = new Input({
source: new BlobSource(file),
formats: ALL_FORMATS,
});
const videoTrack = await input.getPrimaryVideoTrack();
if (!videoTrack) {
throw new Error('File has no video track.');
}
if (videoTrack.codec === null) {
throw new Error('Unsupported video codec.');
}
if (!(await videoTrack.canDecode())) {
throw new Error('Unable to decode the video track.');
}
const output = new Output({
format: new WebMOutputFormat(),
target: new BufferTarget(),
});
const conversion = await Conversion.init({
input,
output,
video: {
crop: {
top: parseInt(cropTopInput.value) || 0,
left: parseInt(cropLeftInput.value) || 0,
width: parseInt(cropWidthInput.value) || 300,
height: parseInt(cropHeightInput.value) || 300,
},
},
});
await conversion.execute();
const buffer = output.target.buffer;
if (!buffer) {
throw new Error('Failed to generate output buffer');
}
const blob = new Blob([buffer], { type: 'video/webm' });
const url = URL.createObjectURL(blob);
const video = document.createElement('video');
video.src = url;
video.controls = true;
video.className = 'rounded-lg overflow-hidden bg-zinc-100 dark:bg-zinc-800';
outputContainer.appendChild(video);
} catch (error) {
console.error(error);
errorElement.textContent = String(error);
outputContainer.innerHTML = '';
}
};
const updateSelectedFile = (file: File | null) => {
selectedFile = file;
fileNameElement.textContent = file ? file.name : '';
cropButton.disabled = !file;
errorElement.textContent = '';
outputContainer.innerHTML = '';
};
selectMediaButton.addEventListener('click', () => {
const fileInput = document.createElement('input');
fileInput.type = 'file';
fileInput.accept = 'video/*,video/x-matroska';
fileInput.addEventListener('change', () => {
const file = fileInput.files?.[0];
updateSelectedFile(file || null);
});
fileInput.click();
});
document.addEventListener('dragover', (event) => {
event.preventDefault();
event.dataTransfer!.dropEffect = 'copy';
});
document.addEventListener('drop', (event) => {
event.preventDefault();
if (!event.dataTransfer?.files) {
updateSelectedFile(null);
return;
}
const file = event.dataTransfer.files[0] ?? null;
updateSelectedFile(file);
});
cropButton.addEventListener('click', () => {
if (selectedFile) {
void cropVideo(selectedFile);
}
});
+70
View File
@@ -0,0 +1,70 @@
<!DOCTYPE html>
<html lang="en" translate="no">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Crop example | Mediabunny</title>
<script type="module" src="./../base.ts"></script>
<script type="module" src="./crop.ts"></script>
<link rel="stylesheet" href="../base.css">
<link rel="icon" href="../../docs/public/mediabunny-logo.svg">
</head>
<body class="flex flex-col items-center py-10 bg-gray-50 text-gray-800 dark:bg-zinc-900 dark:text-zinc-200 px-2">
<h1 class="text-3xl font-bold text-green-500 text-center">Crop example</h1>
<p class="max-w-lg text-center">Select or drop a video file and specify the crop dimensions.</p>
<div class="flex flex-col items-center gap-1">
<div class="flex gap-2 mt-4">
<button id="select-file" class="rounded-lg bg-gray-200 dark:bg-zinc-750 hover:bg-gray-300 dark:hover:bg-zinc-700 px-5 py-2">
Select local file
</button>
</div>
<div class="flex gap-2 mt-4">
<button id="crop-button" class="rounded-lg bg-green-500 hover:bg-green-600 text-white px-5 py-2" disabled>
Crop Video
</button>
</div>
<div class="grid grid-cols-2 gap-4 mt-4">
<div class="flex flex-col gap-1">
<label for="crop-top" class="text-sm">Top</label>
<input type="number" id="crop-top" value="0" class="rounded-lg bg-gray-200 dark:bg-zinc-750 px-3 py-1">
</div>
<div class="flex flex-col gap-1">
<label for="crop-left" class="text-sm">Left</label>
<input type="number" id="crop-left" value="0" class="rounded-lg bg-gray-200 dark:bg-zinc-750 px-3 py-1">
</div>
<div class="flex flex-col gap-1">
<label for="crop-width" class="text-sm">Width</label>
<input type="number" id="crop-width" value="300" class="rounded-lg bg-gray-200 dark:bg-zinc-750 px-3 py-1">
</div>
<div class="flex flex-col gap-1">
<label for="crop-height" class="text-sm">Height</label>
<input type="number" id="crop-height" value="300" class="rounded-lg bg-gray-200 dark:bg-zinc-750 px-3 py-1">
</div>
</div>
</div>
<p class="text-xs opacity-60 mt-2" id="file-name"></p>
<hr class="w-full max-w-96 my-4 border-gray-300 dark:border-zinc-700" style="display: none;">
<p id="error-element" class="text-red-500"></p>
<div id="output-container"></div>
<a href="/" class="fixed top-0 left-0 flex gap-2 py-2 px-5 items-center">
<img src="../../docs/public/mediabunny-logo.svg" class="size-6">
<p class="text-sm font-medium">Mediabunny</p>
</a>
<a
href="https://github.com/Vanilagy/mediabunny/tree/main/examples/crop"
target="_blank"
class="flex items-center gap-2 fixed top-0 right-0 py-2 px-5 bg-gray-200 dark:bg-zinc-750 hover:bg-gray-300 dark:hover:bg-zinc-700 rounded-bl-xl"
>
<img src="../../docs/assets/github-mark.svg" class="size-6 dark:invert">
<p>View source code</p>
</a>
</body>
</html>
+43 -3
View File
@@ -96,6 +96,22 @@ export type ConversionOptions = {
export type ConversionVideoOptions = {
/** If `true`, all video tracks will be discarded and will not be present in the output. */
discard?: boolean;
/**
* Specifies a rectangular region of the input video to crop to, in the coordinate
* system of the original, unrotated frame. Parts of the crop rectangle that extend
* beyond the frame will be filled with black. Cropping is performed before rotation
* and resizing.
*/
crop?: {
/** The distance in pixels from the left edge of the source frame to the left edge of the crop rectangle. */
left: number;
/** The distance in pixels from the top edge of the source frame to the top edge of the crop rectangle. */
top: number;
/** The width in pixels of the crop rectangle. */
width: number;
/** The height in pixels of the crop rectangle. */
height: number;
};
/**
* The desired width of the output video in pixels, defaulting to the video's natural display width. If height
* is not set, it will be deduced automatically based on aspect ratio.
@@ -163,6 +179,24 @@ const validateVideoOptions = (videoOptions: ConversionVideoOptions | undefined)
if (videoOptions?.forceTranscode !== undefined && typeof videoOptions.forceTranscode !== 'boolean') {
throw new TypeError('options.video.forceTranscode, when provided, must be a boolean.');
}
if (videoOptions?.crop !== undefined) {
if (typeof videoOptions.crop !== 'object') {
throw new TypeError('options.video.crop, when provided, must be an object.');
}
const { left, top, width, height } = videoOptions.crop;
if (!Number.isInteger(left)) {
throw new TypeError('options.video.crop.left must be an integer.');
}
if (!Number.isInteger(top)) {
throw new TypeError('options.video.crop.top must be an integer.');
}
if (!Number.isInteger(width) || width <= 0) {
throw new TypeError('options.video.crop.width must be a positive integer.');
}
if (!Number.isInteger(height) || height <= 0) {
throw new TypeError('options.video.crop.height must be a positive integer.');
}
}
if (videoOptions?.codec !== undefined && !VIDEO_CODECS.includes(videoOptions.codec)) {
throw new TypeError(
`options.video.codec, when provided, must be one of: ${VIDEO_CODECS.join(', ')}.`,
@@ -557,9 +591,13 @@ export class Conversion {
const totalRotation = normalizeRotation(track.rotation + (trackOptions.rotate ?? 0));
const outputSupportsRotation = this.output.format.supportsVideoRotationMetadata;
const crop = trackOptions.crop;
const [croppedWidth, croppedHeight] = crop
? [crop.width, crop.height]
: [track.codedWidth, track.codedHeight];
const [originalWidth, originalHeight] = totalRotation % 180 === 0
? [track.codedWidth, track.codedHeight]
: [track.codedHeight, track.codedWidth];
? [croppedWidth, croppedHeight]
: [croppedHeight, croppedWidth];
let width = originalWidth;
let height = originalHeight;
@@ -586,7 +624,8 @@ export class Conversion {
|| !!trackOptions.frameRate;
let needsRerender = width !== originalWidth
|| height !== originalHeight
|| (totalRotation !== 0 && !outputSupportsRotation);
|| (totalRotation !== 0 && !outputSupportsRotation)
|| !!crop;
let videoCodecs = this.output.format.getSupportedVideoCodecs();
if (
@@ -710,6 +749,7 @@ export class Conversion {
height,
fit: trackOptions.fit ?? 'fill',
rotation: totalRotation, // Bake the rotation into the output
crop: trackOptions.crop,
poolSize: 1,
});
const iterator = sink.canvases(this._startTimestamp, this._endTimestamp);
+61 -5
View File
@@ -1002,6 +1002,12 @@ export type WrappedCanvas = {
* @public
*/
export type CanvasSinkOptions = {
/**
* Specifies a rectangular region of the original video frame to crop to, in the coordinate system of the
* unrotated source frame. Parts of the crop rectangle that extend beyond the source frame will be filled with
* black. Cropping is performed before rotation and resizing.
*/
crop?: { left: number; top: number; width: number; height: number };
/**
* The width of the output canvas in pixels, defaulting to the display width of the video track. If height is not
* set, it will be deduced automatically based on aspect ratio.
@@ -1057,6 +1063,8 @@ export class CanvasSink {
/** @internal */
_rotation: Rotation;
/** @internal */
_crop?: { left: number; top: number; width: number; height: number };
/** @internal */
_videoSampleSink: VideoSampleSink;
/** @internal */
_canvasPool: (HTMLCanvasElement | OffscreenCanvas | null)[];
@@ -1077,6 +1085,24 @@ export class CanvasSink {
if (options.height !== undefined && (!Number.isInteger(options.height) || options.height <= 0)) {
throw new TypeError('options.height, when defined, must be a positive integer.');
}
if (options.crop !== undefined) {
if (typeof options.crop !== 'object') {
throw new TypeError('options.crop, when provided, must be an object.');
}
const { left, top, width, height } = options.crop;
if (!Number.isInteger(left)) {
throw new TypeError('options.crop.left must be an integer.');
}
if (!Number.isInteger(top)) {
throw new TypeError('options.crop.top must be an integer.');
}
if (!Number.isInteger(width) || width <= 0) {
throw new TypeError('options.crop.width must be a positive integer.');
}
if (!Number.isInteger(height) || height <= 0) {
throw new TypeError('options.crop.height must be a positive integer.');
}
}
if (options.fit !== undefined && !['fill', 'contain', 'cover'].includes(options.fit)) {
throw new TypeError('options.fit, when provided, must be one of "fill", "contain", or "cover".');
}
@@ -1100,9 +1126,13 @@ export class CanvasSink {
}
const rotation = options.rotation ?? videoTrack.rotation;
const crop = options.crop;
const [croppedWidth, croppedHeight] = crop
? [crop.width, crop.height]
: [videoTrack.codedWidth, videoTrack.codedHeight];
let [width, height] = rotation % 180 === 0
? [videoTrack.codedWidth, videoTrack.codedHeight]
: [videoTrack.codedHeight, videoTrack.codedWidth];
? [croppedWidth, croppedHeight]
: [croppedHeight, croppedWidth];
const originalAspectRatio = width / height;
// If width and height aren't defined together, deduce the missing value using the aspect ratio
@@ -1121,6 +1151,7 @@ export class CanvasSink {
this._width = width;
this._height = height;
this._rotation = rotation;
this._crop = crop;
this._fit = options.fit ?? 'fill';
this._videoSampleSink = new VideoSampleSink(videoTrack);
this._canvasPool = Array.from({ length: options.poolSize ?? 0 }, () => null);
@@ -1162,17 +1193,42 @@ export class CanvasSink {
context.clearRect(0, 0, this._width, this._height);
}
sample.drawWithFit(context, {
let sampleToDraw: VideoSample = sample;
if (this._crop) {
const { left, top, width: cWidth, height: cHeight } = this._crop;
const cropCanvas = typeof document !== 'undefined'
? document.createElement('canvas')
: new OffscreenCanvas(cWidth, cHeight);
cropCanvas.width = cWidth;
cropCanvas.height = cHeight;
const cropCtx = cropCanvas.getContext('2d', { alpha: false }) as
CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D;
assert(cropCtx);
cropCtx.fillStyle = '#000';
cropCtx.fillRect(0, 0, cWidth, cHeight);
cropCtx.drawImage(sample.toCanvasImageSource(), -left, -top);
sampleToDraw = new VideoSample(cropCanvas, {
timestamp: sample.timestamp,
duration: sample.duration,
});
}
sampleToDraw.drawWithFit(context, {
fit: this._fit,
rotation: this._rotation,
});
const result = {
canvas,
timestamp: sample.timestamp,
duration: sample.duration,
timestamp: sampleToDraw.timestamp,
duration: sampleToDraw.duration,
};
if (sampleToDraw !== sample) {
sampleToDraw.close();
}
sample.close();
return result;
}