ultrawidify/src/ext/lib/video-transform/Scaler.ts

279 lines
10 KiB
TypeScript
Raw Normal View History

import AspectRatioType from '../../../common/enums/AspectRatioType.enum';
import VideoData from '../video-data/VideoData';
2025-04-26 04:23:57 +02:00
import { Ar, ArVariant } from '../../../common/interfaces/ArInterface';
2025-05-04 02:18:58 +02:00
import { ComponentLogger } from '../logging/ComponentLogger';
2021-01-30 13:07:19 +01:00
export enum CropStrategy {
/**
* Nomenclature explained:
2021-10-31 23:18:44 +01:00
*
* SP - stream AR < player AR
* PS - the opposite of
2021-10-31 23:18:44 +01:00
*
* ArDominant - given aspect ratio is bigger than stream AR and player AR
* PSDominant - stream AR or player AR are bigger than given aspect ratio
*/
CropLetterbox = 1,
NoCropPillarbox = 2,
NoCropLetterbox = 3,
CropPillarbox = 4
}
export type VideoDimensions = {
xFactor?: number;
yFactor?: number;
cropStrategy?: number;
arCorrectionFactor?: number;
styleHeightCompensationFactor?: number;
actualWidth?: number;
actualHeight?: number;
2024-12-30 03:05:47 +01:00
relativeCropLimits?: {
top: number;
left: number;
},
preventAlignment?: {
x: boolean,
y: boolean
}
}
// does video size calculations for zooming/cropping
class Scaler {
//#region helper objects
conf: VideoData;
2025-05-04 02:18:58 +02:00
logger: ComponentLogger;
//#endregion
// functions
2019-09-03 22:55:10 +02:00
constructor(videoData) {
2018-05-18 23:26:20 +02:00
this.conf = videoData;
2025-05-04 02:18:58 +02:00
this.logger = new ComponentLogger(videoData.logAggregator, 'Scaler', {});
}
2021-10-31 23:18:44 +01:00
// handles "legacy" options, such as 'fit to width', 'fit to height' and AspectRatioType.Reset. No zoom tho
modeToAr (ar) {
if (ar.type !== AspectRatioType.FitWidth && ar.type !== AspectRatioType.FitHeight && ar.ratio) {
2021-10-31 23:18:44 +01:00
return ar.ratio;
2019-03-10 23:27:50 +01:00
}
2021-02-18 22:38:32 +01:00
let ratioOut;
if (!this.conf.video) {
2025-05-04 02:18:58 +02:00
this.logger.error('modeToAr', "No video??",this.conf.video, "killing videoData");
2018-05-18 23:26:20 +02:00
this.conf.destroy();
return null;
}
2021-10-31 23:18:44 +01:00
2020-09-20 12:26:03 +02:00
if (!this.conf.player.dimensions) {
2019-03-10 23:27:50 +01:00
ratioOut = screen.width / screen.height;
2020-09-20 12:26:03 +02:00
} else {
2019-03-10 23:27:50 +01:00
ratioOut = this.conf.player.dimensions.width / this.conf.player.dimensions.height;
}
2021-10-31 23:18:44 +01:00
2018-08-20 22:45:43 +02:00
// IMPORTANT NOTE: lastAr needs to be set after _res_setAr() is called, as _res_setAr() assumes we're
2021-10-31 23:18:44 +01:00
// setting a static aspect ratio (even if the function is called from here or ArDetect).
2021-02-18 22:38:32 +01:00
let fileAr = this.conf.video.videoWidth / this.conf.video.videoHeight;
2021-10-31 23:18:44 +01:00
if (ar.type === AspectRatioType.FitWidth) {
2019-03-10 23:27:50 +01:00
ratioOut > fileAr ? ratioOut : fileAr
ar.ratio = ratioOut;
return ratioOut;
}
else if (ar.type === AspectRatioType.FitHeight) {
2019-03-10 23:27:50 +01:00
ratioOut < fileAr ? ratioOut : fileAr
ar.ratio = ratioOut;
return ratioOut;
}
else if (ar.type === AspectRatioType.Reset) {
2025-05-04 02:18:58 +02:00
this.logger.info('modeToAr', "Using original aspect ratio -", fileAr)
2019-03-10 23:27:50 +01:00
ar.ar = fileAr;
return fileAr;
}
return null;
}
2025-04-26 04:23:57 +02:00
calculateCrop(ar: Ar): VideoDimensions | {error: string, [x: string]: any} {
/**
* STEP 1: NORMALIZE ASPECT RATIO
*
2021-10-31 23:18:44 +01:00
* Video width is normalized based on 100% of the parent. That means if the player AR
* is narrower than video ar, we need to pre-downscale the video. This scaling already
2021-10-31 23:18:44 +01:00
* undoes any zoom that style="height:123%" on the video element adds.
*
* There are few exceptions and additional caveats:
* * AspectRatioType.FitHeight: we don't want to pre-downscale the video at all, as things
* will be scaled to fit height as-is.
* * When player is wider than stream, we want to undo any height compensations site
* tacks on the video tag.
2021-10-31 23:18:44 +01:00
*
* Quick notes:
* * when I say 'video AR', I actually mean aspect ratio after we've compensated for
* any possible 'height:' stuffs in the style attribute of the video tag
* * because video width is normalized on 100% of the parent, we don't need to correct
* anything when the player is wider than the video.
*/
const streamAr = this.conf.aspectRatio;
const playerAr = this.conf.player.aspectRatio;
const heightCompensationFactor = this.conf.getHeightCompensationFactor();
const compensatedStreamAr = streamAr * heightCompensationFactor;
let arCorrectionFactor = 1;
2020-12-20 01:00:06 +01:00
if (ar.type !== AspectRatioType.FitHeight) {
2020-12-20 01:00:06 +01:00
if (playerAr < compensatedStreamAr) {
arCorrectionFactor = this.conf.player.dimensions.width / this.conf.video.offsetWidth;
} else if (ar.type !== AspectRatioType.Reset) {
2020-12-20 01:00:06 +01:00
arCorrectionFactor /= heightCompensationFactor;
}
}
if(!this.conf.video){
2025-05-04 02:18:58 +02:00
this.logger.info('calculateCrop', "ERROR — no video detected. Conf:", this.conf, "video:", this.conf.video, "video dimensions:", this.conf.video && this.conf.video.videoWidth, '×', this.conf.video && this.conf.video.videoHeight);
2021-10-31 23:18:44 +01:00
2018-05-18 23:26:20 +02:00
this.conf.destroy();
return {error: "no_video"};
}
if (this.conf.video.videoWidth == 0 || this.conf.video.videoHeight == 0) {
// that's illegal, but not illegal enough to just blast our shit to high hell
// mr officer will let you go with a warning this time around
2025-05-04 02:18:58 +02:00
this.logger.error('calculateCrop', "Video has illegal dimensions. Video dimensions:", this.conf.video && this.conf.video.videoWidth, '×', this.conf.video && this.conf.video.videoHeight);
return {error: "illegal_video_dimensions"};
}
if (ar.type === AspectRatioType.Reset){
2024-12-30 03:05:47 +01:00
return {
xFactor: arCorrectionFactor,
yFactor: arCorrectionFactor,
arCorrectionFactor: arCorrectionFactor,
relativeCropLimits: {
top: 0,
left: 0,
}
};
}
// handle fuckie-wuckies
if (!ar.ratio){
2025-05-04 02:18:58 +02:00
this.logger.error('calculateCrop', "no ar?", ar.ratio, " -- we were given this mode:", ar);
2019-04-25 22:02:10 +02:00
return {error: "no_ar", ratio: ar.ratio};
}
2025-05-04 02:18:58 +02:00
this.logger.info('calculateCrop', "trying to set ar. args are: ar->",ar.ratio,"; this.conf.player.dimensions->",this.conf.player.dimensions.width, "×", this.conf.player.dimensions.height, "| obj:", this.conf.player.dimensions);
2025-04-26 04:23:57 +02:00
// If we encounter invalid players, we try to update its dimensions
// ONCE before throwing an error
if( (! this.conf.player.dimensions) || this.conf.player.dimensions.width === 0 || this.conf.player.dimensions.height === 0 ){
2025-05-04 02:18:58 +02:00
this.logger.error('calculateCrop', "ERROR — no (or invalid) this.conf.player.dimensions:",this.conf.player.dimensions);
2025-04-26 04:23:57 +02:00
this.conf.player.updatePlayer();
if( (! this.conf.player.dimensions) || this.conf.player.dimensions.width === 0 || this.conf.player.dimensions.height === 0 ){
return {error: "this.conf.player.dimensions_error"};
}
}
// we can finally start computing required video dimensions now:
// Actual aspect ratio of the file/<video> tag
if (ar.type === AspectRatioType.Initial || !ar.ratio) {
ar.ratio = streamAr;
}
2021-10-31 23:18:44 +01:00
2025-05-04 02:18:58 +02:00
this.logger.info('calculateCrop', "ar is " ,ar.ratio, ", file ar is", streamAr, ",ar variant", ar.variant ,"\nthis.conf.player.dimensions are ", this.conf.player.dimensions.width, "×", this.conf.player.dimensions.height, "| obj:", this.conf.player.dimensions, this.conf.player.element);
2021-10-31 23:18:44 +01:00
const videoDimensions: VideoDimensions = {
xFactor: 1,
yFactor: 1,
actualWidth: 0, // width of the video (excluding pillarbox) when <video> tag height is equal to width
actualHeight: 0, // height of the video (excluding letterbox) when <video> tag height is equal to height
2021-01-31 17:44:24 +01:00
arCorrectionFactor: arCorrectionFactor,
2024-12-30 03:05:47 +01:00
styleHeightCompensationFactor: heightCompensationFactor,
relativeCropLimits: {
top: 0,
left: 0
}
}
2021-10-31 23:18:44 +01:00
2025-04-26 04:23:57 +02:00
this.calculateCropCore(videoDimensions, ar.ratio, streamAr, playerAr, ar.variant)
2021-10-31 23:18:44 +01:00
2021-01-31 17:44:24 +01:00
return videoDimensions;
}
/**
* The act of calculating aspect ratio is separated due to resue elsewhere in the extension.
* We are doing that to avoid surprise recursions.
2021-10-31 23:18:44 +01:00
* @param {*} videoDimensions
* @param {*} ar
* @param {*} streamAr
* @param {*} playerAr
2021-01-31 17:44:24 +01:00
*/
2025-04-26 04:23:57 +02:00
calculateCropCore(videoDimensions: VideoDimensions, ar: number, streamAr: number, playerAr: number, variant?: ArVariant) {
if (variant === ArVariant.Zoom) {
playerAr = ar;
}
if (streamAr < playerAr) {
2021-01-31 17:44:24 +01:00
if (streamAr < ar){
2019-10-27 22:11:07 +01:00
// in this situation we have to crop letterbox on top/bottom of the player
// we cut it, but never more than the player
2021-01-31 17:44:24 +01:00
videoDimensions.xFactor = Math.min(ar, playerAr) / streamAr;
videoDimensions.yFactor = videoDimensions.xFactor;
videoDimensions.cropStrategy = CropStrategy.CropLetterbox;
2019-10-27 22:11:07 +01:00
} else {
// in this situation, we would be cutting pillarbox. Inside horizontal player.
// I don't think so. Except exceptions, we'll wait for bug reports.
videoDimensions.xFactor = 1;
videoDimensions.yFactor = 1;
videoDimensions.cropStrategy = CropStrategy.NoCropPillarbox;
2019-10-27 22:11:07 +01:00
}
} else {
2021-01-31 17:44:24 +01:00
if (streamAr < ar || playerAr < ar){
2019-10-27 22:11:07 +01:00
// in this situation, we need to add extra letterbox on top of our letterbox
// this means we simply don't crop anything _at all_
videoDimensions.xFactor = 1;
videoDimensions.yFactor = 1;
videoDimensions.cropStrategy = CropStrategy.NoCropLetterbox;
2019-10-27 22:11:07 +01:00
} else {
// meant for handling pillarbox crop. not quite implemented.
videoDimensions.xFactor = streamAr / Math.min(ar, playerAr);
videoDimensions.yFactor = videoDimensions.xFactor;
videoDimensions.cropStrategy = CropStrategy.CropPillarbox;
// videoDimensions.xFactor = Math.max(ar.ratio, playerAr) * fileAr;
// videoDimensions.yFactor = videoDimensions.xFactor;
2019-10-27 22:11:07 +01:00
}
}
2021-01-30 12:16:37 +01:00
2021-01-31 17:44:24 +01:00
// correct the scale factor
if (videoDimensions.arCorrectionFactor) {
videoDimensions.xFactor *= videoDimensions.arCorrectionFactor;
videoDimensions.yFactor *= videoDimensions.arCorrectionFactor;
2021-01-30 12:16:37 +01:00
}
2021-01-31 23:32:32 +01:00
2024-12-30 03:05:47 +01:00
// Add crop limits — needed for vertical alignment in order to
const letterboxRatio = (1 - (playerAr / ar));
videoDimensions.relativeCropLimits = {
2025-04-26 04:23:57 +02:00
top: ar > streamAr ? ( ar >= playerAr ? (letterboxRatio * -0.5) : 0) : 0,
left: ar < streamAr ? ( ar <= playerAr ? (-0.5 / letterboxRatio) : 0) : 0,
2024-12-30 03:05:47 +01:00
}
videoDimensions.preventAlignment = {
x: ar > playerAr, // video is wider than player, so it's full width already
y: ar < playerAr, // video is narrower than player, so it's full height already
}
2021-01-31 23:32:32 +01:00
return videoDimensions;
}
}
export default Scaler;