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, chunked: true,
chunkSize: 2**20 chunkSize: 2**20
}); });
const outputFormat = new Mediabunny.WavOutputFormat({}); const outputFormat = new Mediabunny.Mp4OutputFormat({});
const button = document.createElement('button'); const button = document.createElement('button');
button.textContent = 'Cancel'; button.textContent = 'Cancel';
@@ -72,7 +72,7 @@
}), }),
output, output,
audio: { audio: {
codec: 'pcm-s16', //codec: 'pcm-s16',
//sampleRate: 16000, //sampleRate: 16000,
//numberOfChannels: 1, //numberOfChannels: 1,
//discard: true, //discard: true,
@@ -109,6 +109,16 @@
*/ */
video: () => ({ video: () => ({
//discard: true, //discard: true,
crop: {
left: 0,
top: 0,
width: 500,
height: 500,
},
rotate: 90,
width: 200,
height: 500,
fit: 'contain',
//forceTranscode: true, //forceTranscode: true,
//codec: 'avc', //codec: 'avc',
//fit: 'contain', //fit: 'contain',
@@ -132,8 +142,8 @@
//height: 100, //height: 100,
}), }),
trim: { trim: {
start: 0, start: 10,
end: 10 end: 20
}, },
}); });
console.log(conversion); 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 ```ts
type ConversionVideoOptions = { type ConversionVideoOptions = {
discard?: boolean; discard?: boolean;
crop?: { left: number; top: number; width: number; height: number };
width?: number; width?: number;
height?: number; height?: number;
fit?: 'fill' | 'contain' | 'cover'; fit?: 'fill' | 'contain' | 'cover';
rotate?: 0 | 90 | 180 | 270; rotate?: 0 | 90 | 180 | 270;
crop?: { left: number; top: number; width: number; height: number };
frameRate?: number; frameRate?: number;
codec?: VideoCodec; codec?: VideoCodec;
bitrate?: number | Quality; 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`. If you want to get rid of the video track, use `discard: true`.
### Cropping/resizing/rotating video ### Resizing 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: 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. - `'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. - `'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. - `'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` or `crop`, they control the post-rotation, post-crop dimensions.
If `width` or `height` is used in conjunction with `rotation`, they control the post-rotation dimensions.
If you want to apply max/min constraints to a video's dimensions, check out [track-specific options](#track-specific-options). 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'`. 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 ### 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). 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: Here, `options` has the following type:
```ts ```ts
type CanvasSinkOptions = { type CanvasSinkOptions = {
crop?: { left: number; top: number; width: number; height: number };
width?: number; width?: number;
height?: number; height?: number;
fit?: 'fill' | 'contain' | 'cover'; fit?: 'fill' | 'contain' | 'cover';
rotation?: 0 | 90 | 180 | 270; rotation?: 0 | 90 | 180 | 270;
crop?: { left: number; top: number; width: number; height: number };
poolSize?: 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`\ - `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. 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`\ - `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. - `'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. - `'cover'` will scale the image until the entire box is filled, while preserving aspect ratio.
- `rotation`\ - `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`\ - `poolSize`\
See [Canvas pool](#canvas-pool). See [Canvas pool](#canvas-pool).
+1
View File
@@ -350,6 +350,7 @@ drawWithFit(
options: { options: {
fit: 'fill' | 'contain' | 'cover'; fit: 'fill' | 'contain' | 'cover';
rotation?: Rotation; // Overrides the sample's rotation rotation?: Rotation; // Overrides the sample's rotation
crop?: CropRectangle;
}, },
): void; ): 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'; } from './misc';
import { Output, TrackType } from './output'; import { Output, TrackType } from './output';
import { Mp4OutputFormat } from './output-format'; import { Mp4OutputFormat } from './output-format';
import { AudioSample, VideoSample } from './sample'; import { AudioSample, clampCropRectangle, validateCropRectangle, VideoSample } from './sample';
import { MetadataTags, validateMetadataTags } from './tags'; import { MetadataTags, validateMetadataTags } from './tags';
import { NullTarget } from './target'; import { NullTarget } from './target';
@@ -106,22 +106,6 @@ export type ConversionOptions = {
export type ConversionVideoOptions = { export type ConversionVideoOptions = {
/** If `true`, all video tracks will be discarded and will not be present in the output. */ /** If `true`, all video tracks will be discarded and will not be present in the output. */
discard?: boolean; 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 * 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. * is not set, it will be deduced automatically based on aspect ratio.
@@ -142,10 +126,24 @@ export type ConversionVideoOptions = {
*/ */
fit?: 'fill' | 'contain' | 'cover'; fit?: 'fill' | 'contain' | 'cover';
/** /**
* The angle in degrees to rotate the input video by, clockwise. Rotation is applied before resizing. This * The angle in degrees to rotate the input video by, clockwise. Rotation is applied before cropping and resizing.
* rotation is _in addition to_ the natural rotation of the input video as specified in input file's metadata. * This rotation is _in addition to_ the natural rotation of the input video as specified in input file's metadata.
*/ */
rotate?: Rotation; 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 * 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). * be used (which may be variable).
@@ -189,24 +187,6 @@ const validateVideoOptions = (videoOptions: ConversionVideoOptions | undefined)
if (videoOptions?.forceTranscode !== undefined && typeof videoOptions.forceTranscode !== 'boolean') { if (videoOptions?.forceTranscode !== undefined && typeof videoOptions.forceTranscode !== 'boolean') {
throw new TypeError('options.video.forceTranscode, when provided, must be a 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)) { if (videoOptions?.codec !== undefined && !VIDEO_CODECS.includes(videoOptions.codec)) {
throw new TypeError( throw new TypeError(
`options.video.codec, when provided, must be one of: ${VIDEO_CODECS.join(', ')}.`, `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)) { 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.'); throw new TypeError('options.video.rotate, when provided, must be 0, 90, 180 or 270.');
} }
if (videoOptions?.crop !== undefined) {
validateCropRectangle(videoOptions.crop);
}
if ( if (
videoOptions?.frameRate !== undefined videoOptions?.frameRate !== undefined
&& (!Number.isFinite(videoOptions.frameRate) || videoOptions.frameRate <= 0) && (!Number.isFinite(videoOptions.frameRate) || videoOptions.frameRate <= 0)
@@ -635,13 +618,18 @@ export class Conversion {
const totalRotation = normalizeRotation(track.rotation + (trackOptions.rotate ?? 0)); const totalRotation = normalizeRotation(track.rotation + (trackOptions.rotate ?? 0));
const outputSupportsRotation = this.output.format.supportsVideoRotationMetadata; 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 crop = trackOptions.crop;
const [croppedWidth, croppedHeight] = crop if (crop) {
clampCropRectangle(crop, rotatedWidth, rotatedHeight);
}
const [originalWidth, originalHeight] = crop
? [crop.width, crop.height] ? [crop.width, crop.height]
: [track.codedWidth, track.codedHeight]; : [rotatedWidth, rotatedHeight];
const [originalWidth, originalHeight] = totalRotation % 180 === 0
? [croppedWidth, croppedHeight]
: [croppedHeight, croppedWidth];
let width = originalWidth; let width = originalWidth;
let height = originalHeight; let height = originalHeight;
+1
View File
@@ -160,6 +160,7 @@ export {
AudioSampleCopyToOptions, AudioSampleCopyToOptions,
VideoSample, VideoSample,
VideoSampleInit, VideoSampleInit,
CropRectangle,
} from './sample'; } from './sample';
export { export {
AudioBufferSink, AudioBufferSink,
+24 -58
View File
@@ -29,7 +29,7 @@ import {
} from './misc'; } from './misc';
import { EncodedPacket } from './packet'; import { EncodedPacket } from './packet';
import { fromAlaw, fromUlaw } from './pcm'; import { fromAlaw, fromUlaw } from './pcm';
import { AudioSample, VideoSample } from './sample'; import { AudioSample, clampCropRectangle, CropRectangle, validateCropRectangle, VideoSample } from './sample';
/** /**
* Additional options for controlling packet retrieval. * Additional options for controlling packet retrieval.
@@ -1028,12 +1028,6 @@ export type WrappedCanvas = {
* @public * @public
*/ */
export type CanvasSinkOptions = { 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 * 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. * set, it will be deduced automatically based on aspect ratio.
@@ -1058,6 +1052,11 @@ export type CanvasSinkOptions = {
* Rotation is applied before resizing. * Rotation is applied before resizing.
*/ */
rotation?: Rotation; 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 / * 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 * 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)) { if (options.height !== undefined && (!Number.isInteger(options.height) || options.height <= 0)) {
throw new TypeError('options.height, when defined, must be a positive integer.'); 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)) { 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".'); 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)) { 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.'); throw new TypeError('options.rotation, when provided, must be 0, 90, 180 or 270.');
} }
if (options.crop !== undefined) {
validateCropRectangle(options.crop);
}
if ( if (
options.poolSize !== undefined options.poolSize !== undefined
&& (typeof options.poolSize !== 'number' || !Number.isInteger(options.poolSize) || options.poolSize < 0) && (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 rotation = options.rotation ?? videoTrack.rotation;
const [rotatedWidth, rotatedHeight] = rotation % 180 === 0
? [videoTrack.codedWidth, videoTrack.codedHeight]
: [videoTrack.codedHeight, videoTrack.codedWidth];
const crop = options.crop; const crop = options.crop;
const [croppedWidth, croppedHeight] = crop if (crop) {
clampCropRectangle(crop, rotatedWidth, rotatedHeight);
}
let [width, height] = crop
? [crop.width, crop.height] ? [crop.width, crop.height]
: [videoTrack.codedWidth, videoTrack.codedHeight]; : [rotatedWidth, rotatedHeight];
let [width, height] = rotation % 180 === 0
? [croppedWidth, croppedHeight]
: [croppedHeight, croppedWidth];
const originalAspectRatio = width / height; const originalAspectRatio = width / height;
// If width and height aren't defined together, deduce the missing value using the aspect ratio // 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); context.clearRect(0, 0, this._width, this._height);
} }
let sampleToDraw: VideoSample = sample; sample.drawWithFit(context, {
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, fit: this._fit,
rotation: this._rotation, rotation: this._rotation,
crop: this._crop,
}); });
const result = { const result = {
canvas, canvas,
timestamp: sampleToDraw.timestamp, timestamp: sample.timestamp,
duration: sampleToDraw.duration, duration: sample.duration,
}; };
if (sampleToDraw !== sample) {
sampleToDraw.close();
}
sample.close(); sample.close();
return result; return result;
} }
+119 -26
View File
@@ -507,28 +507,7 @@ export class VideoSample {
throw new Error('VideoSample is closed.'); 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 ({ sx, sy, sWidth, sHeight } = this._rotateSourceRegion(sx, sy, sWidth, sHeight, this.rotation));
// 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,
];
}
const source = this.toCanvasImageSource(); const source = this.toCanvasImageSource();
@@ -576,26 +555,69 @@ export class VideoSample {
fit: 'fill' | 'contain' | 'cover'; fit: 'fill' | 'contain' | 'cover';
/** A way to override rotation. Defaults to the rotation of the sample. */ /** A way to override rotation. Defaults to the rotation of the sample. */
rotation?: Rotation; 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 canvasWidth = context.canvas.width;
const canvasHeight = context.canvas.height; const canvasHeight = context.canvas.height;
const rotation = options.rotation ?? this.rotation; 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 // These variables specify where the final sample will be drawn on the canvas
let dx: number; let dx: number;
let dy: number; let dy: number;
let newWidth: number; let newWidth: number;
let newHeight: 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') { if (options.fit === 'fill') {
dx = 0; dx = 0;
dy = 0; dy = 0;
newWidth = canvasWidth; newWidth = canvasWidth;
newHeight = canvasHeight; newHeight = canvasHeight;
} else { } else {
const [sampleWidth, sampleHeight] = rotation % 180 === 0 const [sampleWidth, sampleHeight] = options.crop
? [this.codedWidth, this.codedHeight] ? [options.crop.width, options.crop.height]
: [this.codedHeight, this.codedWidth]; : [rotatedWidth, rotatedHeight];
const scale = options.fit === 'contain' const scale = options.fit === 'contain'
? Math.min(canvasWidth / sampleWidth, canvasHeight / sampleHeight) ? 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 // Important that we don't use .draw() here since that would take rotation into account, but we wanna handle it
// ourselves here // 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; 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( const AUDIO_SAMPLE_FORMATS = new Set(
['f32', 'f32-planar', 's16', 's16-planar', 's32', 's32-planar', 'u8', 'u8-planar'], ['f32', 'f32-planar', 's16', 's16-planar', 's32', 's32-planar', 'u8', 'u8-planar'],
); );