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
+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;
}