mirror of
https://github.com/arcodange-org/mediabunny.git
synced 2026-09-27 19:03:46 +02:00
feat: add video crop option
This commit is contained in:
committed by
Pablo Cuadrado
parent
605c258029
commit
904fd8f95e
+43
-3
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user