Clean up cropping logic

This commit is contained in:
Vanilagy
2025-09-12 16:48:35 +02:00
parent 4aca172d14
commit eba1f360d5
10 changed files with 204 additions and 350 deletions
+14 -4
View File
@@ -24,7 +24,7 @@
chunked: true,
chunkSize: 2**20
});
const outputFormat = new Mediabunny.WavOutputFormat({});
const outputFormat = new Mediabunny.Mp4OutputFormat({});
const button = document.createElement('button');
button.textContent = 'Cancel';
@@ -72,7 +72,7 @@
}),
output,
audio: {
codec: 'pcm-s16',
//codec: 'pcm-s16',
//sampleRate: 16000,
//numberOfChannels: 1,
//discard: true,
@@ -109,6 +109,16 @@
*/
video: () => ({
//discard: true,
crop: {
left: 0,
top: 0,
width: 500,
height: 500,
},
rotate: 90,
width: 200,
height: 500,
fit: 'contain',
//forceTranscode: true,
//codec: 'avc',
//fit: 'contain',
@@ -132,8 +142,8 @@
//height: 100,
}),
trim: {
start: 0,
end: 10
start: 10,
end: 20
},
});
console.log(conversion);
+11 -7
View File
@@ -105,11 +105,11 @@ 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';
rotate?: 0 | 90 | 180 | 270;
crop?: { left: number; top: number; width: number; height: number };
frameRate?: number;
codec?: VideoCodec;
bitrate?: number | Quality;
@@ -138,23 +138,27 @@ 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`.
### 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.
### Resizing video
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 and happens after cropping.
If `width` or `height` is used in conjunction with `rotation`, they control the post-rotation dimensions.
If `width` or `height` is used in conjunction with `rotation` or `crop`, they control the post-rotation, post-crop dimensions.
If you want to apply max/min constraints to a video's dimensions, check out [track-specific options](#track-specific-options).
In the rare case that the input video changes size over time, the `fit` field can be used to control the size change behavior (see [`VideoEncodingConfig`](./media-sources#video-encoding-config)). When unset, the behavior is `'passThrough'`.
### Rotating video
`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 before cropping and resizing.
### Cropping 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.
### Adjusting frame rate
The `frameRate` property can be used to set the frame rate of the output video in Hz. If not specified, the original input frame rate will be used (which may be variable).
+4 -4
View File
@@ -330,16 +330,14 @@ 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';
rotation?: 0 | 90 | 180 | 270;
crop?: { left: number; top: number; width: number; height: number };
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`\
@@ -350,7 +348,9 @@ 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 after cropping and 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 before cropping and resizing.
- `crop`\
Specifies the rectangular region of the input video to crop to. The crop region will automatically be clamped to the dimensions of the input video track. Cropping is performed after rotation but before resizing.
- `poolSize`\
See [Canvas pool](#canvas-pool).
+1
View File
@@ -350,6 +350,7 @@ drawWithFit(
options: {
fit: 'fill' | 'contain' | 'cover';
rotation?: Rotation; // Overrides the sample's rotation
crop?: CropRectangle;
},
): void;
```
-139
View File
@@ -1,139 +0,0 @@
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
@@ -1,70 +0,0 @@
<!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>
+30 -42
View File
@@ -47,7 +47,7 @@ import {
} from './misc';
import { Output, TrackType } from './output';
import { Mp4OutputFormat } from './output-format';
import { AudioSample, VideoSample } from './sample';
import { AudioSample, clampCropRectangle, validateCropRectangle, VideoSample } from './sample';
import { MetadataTags, validateMetadataTags } from './tags';
import { NullTarget } from './target';
@@ -106,22 +106,6 @@ 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.
@@ -142,10 +126,24 @@ export type ConversionVideoOptions = {
*/
fit?: 'fill' | 'contain' | 'cover';
/**
* The angle in degrees to rotate the input video by, clockwise. Rotation is applied before resizing. This
* rotation is _in addition to_ the natural rotation of the input video as specified in input file's metadata.
* The angle in degrees to rotate the input video by, clockwise. Rotation is applied before cropping and resizing.
* This rotation is _in addition to_ the natural rotation of the input video as specified in input file's metadata.
*/
rotate?: Rotation;
/**
* Specifies the rectangular region of the input video to crop to. The crop region will automatically be clamped to
* the dimensions of the input video track. Cropping is performed after rotation but before 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 frame rate of the output video, in hertz. If not specified, the original input frame rate will
* be used (which may be variable).
@@ -189,24 +187,6 @@ 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(', ')}.`,
@@ -247,6 +227,9 @@ const validateVideoOptions = (videoOptions: ConversionVideoOptions | undefined)
if (videoOptions?.rotate !== undefined && ![0, 90, 180, 270].includes(videoOptions.rotate)) {
throw new TypeError('options.video.rotate, when provided, must be 0, 90, 180 or 270.');
}
if (videoOptions?.crop !== undefined) {
validateCropRectangle(videoOptions.crop);
}
if (
videoOptions?.frameRate !== undefined
&& (!Number.isFinite(videoOptions.frameRate) || videoOptions.frameRate <= 0)
@@ -635,13 +618,18 @@ export class Conversion {
const totalRotation = normalizeRotation(track.rotation + (trackOptions.rotate ?? 0));
const outputSupportsRotation = this.output.format.supportsVideoRotationMetadata;
const [rotatedWidth, rotatedHeight] = totalRotation % 180 === 0
? [track.codedWidth, track.codedHeight]
: [track.codedHeight, track.codedWidth];
const crop = trackOptions.crop;
const [croppedWidth, croppedHeight] = crop
if (crop) {
clampCropRectangle(crop, rotatedWidth, rotatedHeight);
}
const [originalWidth, originalHeight] = crop
? [crop.width, crop.height]
: [track.codedWidth, track.codedHeight];
const [originalWidth, originalHeight] = totalRotation % 180 === 0
? [croppedWidth, croppedHeight]
: [croppedHeight, croppedWidth];
: [rotatedWidth, rotatedHeight];
let width = originalWidth;
let height = originalHeight;
+1
View File
@@ -160,6 +160,7 @@ export {
AudioSampleCopyToOptions,
VideoSample,
VideoSampleInit,
CropRectangle,
} from './sample';
export {
AudioBufferSink,
+24 -58
View File
@@ -29,7 +29,7 @@ import {
} from './misc';
import { EncodedPacket } from './packet';
import { fromAlaw, fromUlaw } from './pcm';
import { AudioSample, VideoSample } from './sample';
import { AudioSample, clampCropRectangle, CropRectangle, validateCropRectangle, VideoSample } from './sample';
/**
* Additional options for controlling packet retrieval.
@@ -1028,12 +1028,6 @@ 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.
@@ -1058,6 +1052,11 @@ export type CanvasSinkOptions = {
* Rotation is applied before resizing.
*/
rotation?: Rotation;
/**
* Specifies the rectangular region of the input video to crop to. The crop region will automatically be clamped to
* the dimensions of the input video track. Cropping is performed after rotation but before resizing.
*/
crop?: CropRectangle;
/**
* When set, specifies the number of canvases in the pool. These canvases will be reused in a ring buffer /
* round-robin type fashion. This keeps the amount of allocated VRAM constant and relieves the browser from
@@ -1111,24 +1110,6 @@ 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".');
}
@@ -1144,6 +1125,9 @@ export class CanvasSink {
if (options.rotation !== undefined && ![0, 90, 180, 270].includes(options.rotation)) {
throw new TypeError('options.rotation, when provided, must be 0, 90, 180 or 270.');
}
if (options.crop !== undefined) {
validateCropRectangle(options.crop);
}
if (
options.poolSize !== undefined
&& (typeof options.poolSize !== 'number' || !Number.isInteger(options.poolSize) || options.poolSize < 0)
@@ -1152,13 +1136,19 @@ export class CanvasSink {
}
const rotation = options.rotation ?? videoTrack.rotation;
const [rotatedWidth, rotatedHeight] = rotation % 180 === 0
? [videoTrack.codedWidth, videoTrack.codedHeight]
: [videoTrack.codedHeight, videoTrack.codedWidth];
const crop = options.crop;
const [croppedWidth, croppedHeight] = crop
if (crop) {
clampCropRectangle(crop, rotatedWidth, rotatedHeight);
}
let [width, height] = crop
? [crop.width, crop.height]
: [videoTrack.codedWidth, videoTrack.codedHeight];
let [width, height] = rotation % 180 === 0
? [croppedWidth, croppedHeight]
: [croppedHeight, croppedWidth];
: [rotatedWidth, rotatedHeight];
const originalAspectRatio = width / height;
// If width and height aren't defined together, deduce the missing value using the aspect ratio
@@ -1219,42 +1209,18 @@ export class CanvasSink {
context.clearRect(0, 0, this._width, this._height);
}
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, {
sample.drawWithFit(context, {
fit: this._fit,
rotation: this._rotation,
crop: this._crop,
});
const result = {
canvas,
timestamp: sampleToDraw.timestamp,
duration: sampleToDraw.duration,
timestamp: sample.timestamp,
duration: sample.duration,
};
if (sampleToDraw !== sample) {
sampleToDraw.close();
}
sample.close();
return result;
}
+119 -26
View File
@@ -507,28 +507,7 @@ export class VideoSample {
throw new Error('VideoSample is closed.');
}
// The provided sx,sy,sWidth,sHeight refer to the final rotated image, but that's not actually how the image is
// stored. Therefore, we must map these back onto the original, pre-rotation image.
if (this.rotation === 90) {
[sx, sy, sWidth, sHeight] = [
sy,
this.codedHeight - sx - sWidth,
sHeight,
sWidth,
];
} else if (this.rotation === 180) {
[sx, sy] = [
this.codedWidth - sx - sWidth,
this.codedHeight - sy - sHeight,
];
} else if (this.rotation === 270) {
[sx, sy, sWidth, sHeight] = [
this.codedWidth - sy - sHeight,
sx,
sHeight,
sWidth,
];
}
({ sx, sy, sWidth, sHeight } = this._rotateSourceRegion(sx, sy, sWidth, sHeight, this.rotation));
const source = this.toCanvasImageSource();
@@ -576,26 +555,69 @@ export class VideoSample {
fit: 'fill' | 'contain' | 'cover';
/** A way to override rotation. Defaults to the rotation of the sample. */
rotation?: Rotation;
/**
* Specifies the rectangular region of the video sample to crop to. The crop region will automatically be
* clamped to the dimensions of the video sample. Cropping is performed after rotation but before resizing.
*/
crop?: CropRectangle;
}) {
if (!(
(typeof CanvasRenderingContext2D !== 'undefined' && context instanceof CanvasRenderingContext2D)
|| (
typeof OffscreenCanvasRenderingContext2D !== 'undefined'
&& context instanceof OffscreenCanvasRenderingContext2D
)
)) {
throw new TypeError('context must be a CanvasRenderingContext2D or OffscreenCanvasRenderingContext2D.');
}
if (!options || typeof options !== 'object') {
throw new TypeError('options must be an object.');
}
if (!['fill', 'contain', 'cover'].includes(options.fit)) {
throw new TypeError('options.fit must be \'fill\', \'contain\', or \'cover\'.');
}
if (options.rotation !== undefined && ![0, 90, 180, 270].includes(options.rotation)) {
throw new TypeError('options.rotation, when provided, must be 0, 90, 180, or 270.');
}
if (options.crop !== undefined) {
validateCropRectangle(options.crop);
}
const canvasWidth = context.canvas.width;
const canvasHeight = context.canvas.height;
const rotation = options.rotation ?? this.rotation;
const [rotatedWidth, rotatedHeight] = rotation % 180 === 0
? [this.codedWidth, this.codedHeight]
: [this.codedHeight, this.codedWidth];
if (options.crop) {
clampCropRectangle(options.crop, rotatedWidth, rotatedHeight);
}
// These variables specify where the final sample will be drawn on the canvas
let dx: number;
let dy: number;
let newWidth: number;
let newHeight: number;
const { sx, sy, sWidth, sHeight } = this._rotateSourceRegion(
options.crop?.left ?? 0,
options.crop?.top ?? 0,
options.crop?.width ?? this.codedWidth,
options.crop?.height ?? this.codedHeight,
rotation,
);
if (options.fit === 'fill') {
dx = 0;
dy = 0;
newWidth = canvasWidth;
newHeight = canvasHeight;
} else {
const [sampleWidth, sampleHeight] = rotation % 180 === 0
? [this.codedWidth, this.codedHeight]
: [this.codedHeight, this.codedWidth];
const [sampleWidth, sampleHeight] = options.crop
? [options.crop.width, options.crop.height]
: [rotatedWidth, rotatedHeight];
const scale = options.fit === 'contain'
? Math.min(canvasWidth / sampleWidth, canvasHeight / sampleHeight)
@@ -616,7 +638,35 @@ export class VideoSample {
// Important that we don't use .draw() here since that would take rotation into account, but we wanna handle it
// ourselves here
context.drawImage(this.toCanvasImageSource(), dx, dy, newWidth, newHeight);
context.drawImage(this.toCanvasImageSource(), sx, sy, sWidth, sHeight, dx, dy, newWidth, newHeight);
}
/** @internal */
_rotateSourceRegion(sx: number, sy: number, sWidth: number, sHeight: number, rotation: number) {
// The provided sx,sy,sWidth,sHeight refer to the final rotated image, but that's not actually how the image is
// stored. Therefore, we must map these back onto the original, pre-rotation image.
if (rotation === 90) {
[sx, sy, sWidth, sHeight] = [
sy,
this.codedHeight - sx - sWidth,
sHeight,
sWidth,
];
} else if (rotation === 180) {
[sx, sy] = [
this.codedWidth - sx - sWidth,
this.codedHeight - sy - sHeight,
];
} else if (rotation === 270) {
[sx, sy, sWidth, sHeight] = [
this.codedWidth - sy - sHeight,
sx,
sHeight,
sWidth,
];
}
return { sx, sy, sWidth, sHeight };
}
/**
@@ -679,6 +729,49 @@ const isVideoFrame = (x: unknown): x is VideoFrame => {
return typeof VideoFrame !== 'undefined' && x instanceof VideoFrame;
};
/**
* Specifies the rectangular cropping region.
* @public
*/
export type CropRectangle = {
/** 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;
};
export const clampCropRectangle = (crop: CropRectangle, outerWidth: number, outerHeight: number) => {
crop.left = Math.min(crop.left, outerWidth);
crop.top = Math.min(crop.top, outerHeight);
crop.width = Math.min(crop.width, outerWidth - crop.left);
crop.height = Math.min(crop.height, outerHeight - crop.top);
assert(crop.width >= 0);
assert(crop.height >= 0);
};
export const validateCropRectangle = (crop: CropRectangle) => {
if (!crop || typeof crop !== 'object') {
throw new TypeError('crop, when provided, must be an object.');
}
if (!Number.isInteger(crop.left) || crop.left < 0) {
throw new TypeError('crop.left must be a non-negative integer.');
}
if (!Number.isInteger(crop.top) || crop.top < 0) {
throw new TypeError('crop.top must be a non-negative integer.');
}
if (!Number.isInteger(crop.width) || crop.width < 0) {
throw new TypeError('crop.width must be a non-negative integer.');
}
if (!Number.isInteger(crop.height) || crop.height < 0) {
throw new TypeError('crop.height must be a non-negative integer.');
}
};
const AUDIO_SAMPLE_FORMATS = new Set(
['f32', 'f32-planar', 's16', 's16-planar', 's32', 's32-planar', 'u8', 'u8-planar'],
);