ultrawidify/src/ext/module/aard/Aard.ts

1440 lines
52 KiB
TypeScript
Raw Normal View History

2025-04-26 04:23:57 +02:00
import AspectRatioType from '@src/common/enums/AspectRatioType.enum';
import ExtensionMode from '@src/common/enums/ExtensionMode.enum';
import { ArVariant } from '@src/common/interfaces/ArInterface';
import { ExtensionEnvironment } from '@src/common/interfaces/SettingsInterface';
import EventBus from '../EventBus';
2025-07-15 19:41:00 +02:00
import Settings from '../settings/Settings';
import { SiteSettings } from '../settings/SiteSettings';
import VideoData from '../video-data/VideoData';
2025-04-05 01:31:06 +02:00
import { AardDebugUi } from './AardDebugUi';
2025-04-15 18:51:34 +02:00
import { AardTimer } from './AardTimers';
import { Corner } from './enums/corner.enum';
import { VideoPlaybackState } from './enums/video-playback-state.enum';
import { FallbackCanvas } from './gl/FallbackCanvas';
import { GlCanvas } from './gl/GlCanvas';
import { GlDebugCanvas, GlDebugType } from './gl/GlDebugCanvas';
import { AardCanvasStore } from './interfaces/aard-canvas-store.interface';
import { AardDetectionSample, generateSampleArray, resetSamples } from './interfaces/aard-detection-sample.interface';
import { AardStatus, initAardStatus } from './interfaces/aard-status.interface';
2025-10-12 22:03:55 +02:00
import { AardTestResult_SubtitleRegion, AardTestResults, initAardTestResults, resetAardTestResults, resetGuardLine, resetSubtitleScanResults } from './interfaces/aard-test-results.interface';
import { AardTimers, initAardTimers } from './interfaces/aard-timers.interface';
2025-05-04 02:18:58 +02:00
import { ComponentLogger } from '../logging/ComponentLogger';
import { AardPollingOptions } from './enums/aard-polling-options.enum';
2025-10-12 22:03:55 +02:00
import { AardSubtitleCropMode } from './enums/aard-subtitle-crop-mode.enum';
import { LetterboxOrientation } from './enums/letterbox-orientation.enum';
import { Edge } from './enums/edge.enum';
import { AardUncertainReason } from './enums/aard-letterbox-uncertain-reason.enum';
2025-10-13 17:35:37 +02:00
import { result } from 'lodash';
2024-10-15 17:38:04 +02:00
/**
* /\
* //\\ Automatic
* // \\ Aspect
* // \\ Ratio
* \\ Detector
* //XXXX \\
* // \\ (Totes not a Witcher reference)
* // \\ (Witcher 2 best Witcher)
* //XXXXXXXXXXXXXX\\
*
*/
2025-10-12 22:03:55 +02:00
const PIXEL_SIZE = 4;
const PIXEL_SIZE_FRACTION = 0.25;
let ROW_SIZE = -1;
export class Aard {
//#region configuration parameters
2025-05-04 02:18:58 +02:00
private logger: ComponentLogger;
private videoData: VideoData;
private settings: Settings;
private siteSettings: SiteSettings;
private eventBus: EventBus;
private arid: string;
2025-04-26 23:24:57 +02:00
private arVariant: ArVariant;
private eventBusCommands = {
'uw-environment-change': {
function: (newEnvironment: ExtensionEnvironment) => {
// console.log('received extension environment:', newEnvironment, 'player env:', this.videoData?.player?.environment);
this.startCheck();
}
},
'aard-enable-debug': {
function: (enabled: boolean) => {
if (enabled) {
this.showDebugCanvas();
} else {
this.hideDebugCanvas();
}
}
},
'url-changed': {
function: () => {
this.clearAutoDisabled();
}
}
// 'get-aard-timing': {
// function: () => this.handlePerformanceDataRequest()
// }
};
//#endregion
private video: HTMLVideoElement;
private animationFrame: number;
//#region internal state
public status: AardStatus = initAardStatus();
private timers: AardTimers = initAardTimers();
private inFallback: boolean = false;
private fallbackReason: any;
private canvasStore: AardCanvasStore;
2024-10-19 16:04:20 +02:00
private testResults: AardTestResults;
2025-04-15 18:51:34 +02:00
private verticalTestResults: AardTestResults;
2024-10-19 16:04:20 +02:00
private canvasSamples: AardDetectionSample;
private forceFullRecheck: boolean = true;
2025-04-05 01:31:06 +02:00
private debugConfig: any = {};
2025-04-15 18:51:34 +02:00
private timer: AardTimer;
2025-04-21 22:24:32 +02:00
private lastAnimationFrameTime: number = Infinity;
//#endregion
//#region getters
get defaultAr() {
if (!this.video) {
return undefined;
}
this.video.setAttribute('crossOrigin', 'anonymous');
const ratio = this.video.videoWidth / this.video.videoHeight;
if (isNaN(ratio)) {
return undefined;
}
return ratio;
}
//#endregion getters
//#region lifecycle
constructor(videoData: VideoData){
2025-05-04 02:18:58 +02:00
this.logger = new ComponentLogger(videoData.logAggregator, 'Aard', {});
this.videoData = videoData;
this.video = videoData.video;
this.settings = videoData.settings;
this.siteSettings = videoData.siteSettings;
this.eventBus = videoData.eventBus;
this.eventBus.subscribeMulti(this.eventBusCommands, this);
this.arid = (Math.random()*100).toFixed();
// we can tick manually, for debugging
2025-05-04 02:18:58 +02:00
this.logger.log('ctor', `creating new ArDetector. arid: ${this.arid}`);
2024-10-15 17:38:04 +02:00
2025-04-21 22:24:32 +02:00
this.timer = new AardTimer();
2024-10-15 17:38:04 +02:00
this.init();
}
/**
* Initializes Aard with default values and starts autodetection loop.
* This method should only ever be called from constructor.
*/
private init() {
this.canvasStore = {
main: this.createCanvas('main-gl')
};
2024-10-19 16:04:20 +02:00
this.canvasSamples = {
top: generateSampleArray(
2025-10-14 18:52:22 +02:00
this.settings.active.aard.sampling.staticCols,
this.settings.active.aard.canvasDimensions.sampleCanvas.width
2024-10-19 16:04:20 +02:00
),
bottom: generateSampleArray(
2025-10-14 18:52:22 +02:00
this.settings.active.aard.sampling.staticCols,
this.settings.active.aard.canvasDimensions.sampleCanvas.width
2024-10-19 16:04:20 +02:00
),
2025-10-12 22:03:55 +02:00
left: generateSampleArray(
2025-10-14 18:52:22 +02:00
this.settings.active.aard.sampling.staticCols,
this.settings.active.aard.canvasDimensions.sampleCanvas.height
2025-10-12 22:03:55 +02:00
),
right: generateSampleArray(
2025-10-14 18:52:22 +02:00
this.settings.active.aard.sampling.staticCols,
this.settings.active.aard.canvasDimensions.sampleCanvas.height
2025-10-12 22:03:55 +02:00
)
2024-10-19 16:04:20 +02:00
};
// try {
// this.showDebugCanvas();
// } catch (e) {
// console.error('FALIED TO CREATE DEBUGG CANVAS', e);
// }
2025-04-21 22:24:32 +02:00
try {
if (this.settings.active.ui.dev?.aardDebugOverlay?.showOnStartup) {
this.showDebugCanvas();
}
} catch (e) {
console.error(`[uw::aard] failed to create debug UI:`, e);
}
this.startCheck();
}
private createCanvas(canvasId: string, canvasType?: 'webgl' | 'legacy') {
2025-10-14 18:52:22 +02:00
ROW_SIZE = this.settings.active.aard.canvasDimensions.sampleCanvas.width * PIXEL_SIZE;
2025-10-12 22:03:55 +02:00
if (canvasType) {
2025-10-14 18:52:22 +02:00
if (canvasType === this.settings.active.aard.aardType || this.settings.active.aard.aardType === 'auto') {
if (canvasType === 'webgl') {
2025-10-14 18:52:22 +02:00
return new GlCanvas({...this.settings.active.aard.canvasDimensions.sampleCanvas, id: 'main-gl'});
} else if (canvasType === 'legacy') {
2025-10-14 18:52:22 +02:00
return new FallbackCanvas({...this.settings.active.aard.canvasDimensions.sampleCanvas, id: 'main-legacy'});
} else {
// TODO: throw error
}
} else {
// TODO: throw error
}
}
2025-10-14 18:52:22 +02:00
if (['auto', 'webgl'].includes(this.settings.active.aard.aardType)) {
try {
2025-10-14 18:52:22 +02:00
return new GlCanvas({...this.settings.active.aard.canvasDimensions.sampleCanvas, id: 'main-gl'});
} catch (e) {
2025-10-14 18:52:22 +02:00
if (this.settings.active.aard.aardType !== 'webgl') {
return new FallbackCanvas({...this.settings.active.aard.canvasDimensions.sampleCanvas, id: 'main-legacy'});
}
2025-05-04 02:18:58 +02:00
this.logger.error('createCanvas', 'could not create webgl canvas:', e);
this.eventBus.send('uw-config-broadcast', {type: 'aard-error', aardErrors: {webglError: true}});
throw e;
}
2025-10-14 18:52:22 +02:00
} else if (this.settings.active.aard.aardType === 'legacy') {
return new FallbackCanvas({...this.settings.active.aard.canvasDimensions.sampleCanvas, id: 'main-legacy'});
} else {
2025-10-14 18:52:22 +02:00
this.logger.error('createCanvas', 'invalid value in settings.arDetect.aardType:', this.settings.active.aard.aardType);
this.eventBus.send('uw-config-broadcast', {type: 'aard-error', aardErrors: {invalidSettings: true}});
throw 'AARD_INVALID_SETTINGS';
}
}
/**
* Creates and shows debug canvas
* @param canvasId
*/
private showDebugCanvas() {
if (!this.canvasStore.debug) {
2025-10-14 18:52:22 +02:00
this.canvasStore.debug = new GlDebugCanvas({...this.settings.active.aard.canvasDimensions.sampleCanvas, id: 'uw-debug-gl'});
}
2025-04-05 01:31:06 +02:00
this.canvasStore.debug.enableFx();
if (!this.debugConfig.debugUi) {
this.debugConfig.debugUi = new AardDebugUi(this);
this.debugConfig.debugUi.initContainer();
this.debugConfig.debugUi.attachCanvases(this.canvasStore.main.canvas, this.canvasStore.debug.canvas);
// if we don't draw a dummy frame from _real_ sources, we can't update buffer later
this.canvasStore.debug.drawVideoFrame(this.canvasStore.main.canvas);
}
}
private hideDebugCanvas() {
if (this.debugConfig.debugUi) {
this.debugConfig?.debugUi.destroyContainer();
this.debugConfig.debugUi = undefined;
}
}
//#endregion
/**
* Checks whether autodetection can run
*/
2025-04-26 04:23:57 +02:00
startCheck(arVariant?: ArVariant) {
2025-04-26 23:24:57 +02:00
this.arVariant = arVariant;
if (!this.videoData.player) {
2025-04-26 23:24:57 +02:00
// console.warn('Player not detected!');
// console.log('--- video data: ---\n', this.videoData);
2025-04-15 18:51:34 +02:00
return;
}
if (this.siteSettings.canRunAard(this.videoData.player.environment)) {
this.start();
} else {
this.stop();
}
}
/**
* Clears autoDisable flag
*/
clearAutoDisabled() {
this.status.autoDisabled = false;
this.timers.autoDisableAt = undefined;
}
/**
* Starts autodetection loop.
*/
start() {
this.clearAutoDisabled();
this.forceFullRecheck = true;
if (this.videoData.resizer.lastAr.type === AspectRatioType.AutomaticUpdate) {
// ensure first autodetection will run in any case
this.videoData.resizer.lastAr = {type: AspectRatioType.AutomaticUpdate, ratio: this.defaultAr};
}
// do full reset of test samples
2025-10-14 18:52:22 +02:00
this.testResults = initAardTestResults(this.settings.active.aard);
this.verticalTestResults = initAardTestResults(this.settings.active.aard);
if (this.animationFrame) {
window.cancelAnimationFrame(this.animationFrame);
}
this.status.aardActive = true;
this.animationFrame = window.requestAnimationFrame( (ts: DOMHighResTimeStamp) => this.onAnimationFrame(ts));
// set auto-disable timer if detection timeout is set
2025-10-14 18:52:22 +02:00
if (this.settings.active.aard.autoDisable.ifNotChanged) {
this.timers.autoDisableAt = Date.now() + this.settings.active.aard.autoDisable.ifNotChangedTimeout;
}
}
/**
* Runs autodetection ONCE.
* If autodetection loop is running, this will also stop autodetection loop.
*/
2025-04-15 18:51:34 +02:00
step(options?: {noCache?: boolean}) {
this.stop();
2025-04-15 18:51:34 +02:00
if (options?.noCache) {
2025-10-14 18:52:22 +02:00
this.testResults = initAardTestResults(this.settings.active.aard);
this.verticalTestResults = initAardTestResults(this.settings.active.aard);
2025-04-15 18:51:34 +02:00
}
this.main();
}
/**
* Stops autodetection.
*/
stop() {
if (this.animationFrame) {
window.cancelAnimationFrame(this.animationFrame);
}
}
//#region animationFrame, scheduling, and other shit
/**
* Checks whether conditions for granting a frame check are fulfilled
* @returns
*/
private canTriggerFrameCheck() {
// console.log('ard check status:', this.status);
// if video was paused & we know that we already checked that frame,
// we will not check it again.
const videoState = this.getVideoPlaybackState();
2025-10-14 18:52:22 +02:00
const polling = this.settings.active.aard.polling;
const now = Date.now();
if (videoState !== VideoPlaybackState.Playing) {
if (this.status.lastVideoStatus === videoState) {
return false;
}
}
if (this.status.autoDisabled) {
return false;
}
if (this.timers.autoDisableAt < now) {
this.status.autoDisabled = true;
return false;
}
this.status.lastVideoStatus = videoState;
const tabVisible = document.visibilityState === 'visible';
if (!tabVisible && polling.runInBackgroundTabs === AardPollingOptions.No) {
return false;
}
if (this.videoData.player.isTooSmall && polling.runOnSmallVideos === AardPollingOptions.No) {
return false;
}
const isActive = (tabVisible || polling.runInBackgroundTabs !== AardPollingOptions.Reduced)
&& (!this.videoData.player.isTooSmall || polling.runOnSmallVideos !== AardPollingOptions.Reduced);
const nextCheck = isActive ? this.timers.nextFrameCheckTime : this.timers.reducedPollingNextCheckTime;
if (now < nextCheck) {
return false;
}
2025-10-14 18:52:22 +02:00
this.timers.nextFrameCheckTime = now + this.settings.active.aard.timers.playing;
this.timers.reducedPollingNextCheckTime = now + this.settings.active.aard.timers.playingReduced;
return true;
}
/**
* Bootstraps the main loop.
*
* Honestly this doesn't need description, but I want to put some green
* between two adjacent functions.
*/
private onAnimationFrame(ts: DOMHighResTimeStamp) {
if (this.canTriggerFrameCheck()) {
resetAardTestResults(this.testResults);
resetSamples(this.canvasSamples);
2025-10-12 22:03:55 +02:00
resetSubtitleScanResults(this.testResults);
this.main();
this.forceFullRecheck = false;
} else {
}
this.animationFrame = window.requestAnimationFrame( (ts: DOMHighResTimeStamp) => this.onAnimationFrame(ts));
}
//#endregion
/**
* Main loop for scanning aspect ratio changes
*/
private async main() {
2025-10-14 18:52:22 +02:00
const arConf = this.settings.active.aard;
2025-10-12 22:03:55 +02:00
try {
2025-04-15 18:51:34 +02:00
this.timer.next();
let imageData: Uint8Array;
2025-04-15 18:51:34 +02:00
this.timer.current.start = performance.now();
// We abuse a do-while loop to eat our cake (get early returns)
// and have it, too (if we return early, we still execute code
// at the end of this function)
2025-10-12 22:03:55 +02:00
scanFrame:
{
imageData = await new Promise<Uint8Array>(
resolve => {
try {
this.canvasStore.main.drawVideoFrame(this.video);
2025-04-15 18:51:34 +02:00
this.timer.current.draw = performance.now() - this.timer.current.start;
resolve(this.canvasStore.main.getImageData());
} catch (e) {
if (e.name === 'SecurityError') {
this.eventBus.send('uw-config-broadcast', {type: 'aard-error', aardErrors: {cors: true}});
this.stop();
}
if (this.canvasStore.main instanceof FallbackCanvas) {
if (this.inFallback) {
this.eventBus.send('uw-config-broadcast', {type: 'aard-error', aardErrors: this.fallbackReason});
this.stop();
} else {
this.eventBus.send('uw-config-broadcast', {type: 'aard-error', aardErrors: {fallbackCanvasError: true}});
this.stop();
}
} else {
2025-10-12 22:03:55 +02:00
if (arConf.aardType === 'auto') {
this.canvasStore.main.destroy();
this.canvasStore.main = this.createCanvas('main-gl', 'legacy');
}
this.inFallback = true;
this.fallbackReason = {cors: true};
2025-10-12 22:03:55 +02:00
if (arConf.aardType !== 'auto') {
this.stop();
}
}
}
}
);
2025-04-15 18:51:34 +02:00
this.timer.current.getImage = performance.now() - this.timer.current.start;
2024-10-15 17:38:04 +02:00
// STEP 1:
// Test if corners are black. If they're not, we can immediately quit the loop.
2025-10-12 22:03:55 +02:00
// For performances of measurements, checking orientation of letterbox is part of fastBlackLevel
const lastValidLetterboxOrientation = this.testResults.lastValidLetterboxOrientation;
orientationCheck:
{
this.getBlackLevelFast(
imageData, 3, 1,
arConf.canvasDimensions.sampleCanvas.width,
arConf.canvasDimensions.sampleCanvas.height
);
if (this.testResults.letterboxOrientation === LetterboxOrientation.NotLetterbox) {
break orientationCheck;
}
this.letterboxOrientationScan(
imageData,
arConf.canvasDimensions.sampleCanvas.width,
arConf.canvasDimensions.sampleCanvas.height
);
}
2025-10-13 17:35:37 +02:00
2025-04-15 18:51:34 +02:00
this.timer.current.fastBlackLevel = performance.now() - this.timer.current.start;
2025-10-12 22:03:55 +02:00
// if we detect no letterbox, we don't test anything — instead, we immediately reset
if (this.testResults.letterboxOrientation === LetterboxOrientation.NotLetterbox) {
// TODO: reset aspect ratio to "AR not applied"
this.testResults.lastStage = 1;
2025-10-12 22:03:55 +02:00
this.testResults.letterboxSize = 0;
2024-12-31 02:50:33 +01:00
this.testResults.letterboxOffset = 0;
2024-12-31 03:14:29 +01:00
resetGuardLine(this.testResults);
2025-10-12 22:03:55 +02:00
break scanFrame;
}
// If we detect both letterbox and pillarbox, we keep things as they are but avoid scanning further
if (this.testResults.letterboxOrientation === LetterboxOrientation.Both) {
this.testResults.lastStage = 1;
break scanFrame;
}
2025-10-12 22:03:55 +02:00
// If lastValidLetterboxOrientation changed, we reset guard line (& gang), but continue processing
if (lastValidLetterboxOrientation !== this.testResults.lastValidLetterboxOrientation) {
this.forceFullRecheck = true;
}
// We only do subtitle check when orientation is letterbox
if (this.testResults.letterboxOrientation === LetterboxOrientation.Letterbox) {
this.subtitleScan(
imageData,
arConf.canvasDimensions.sampleCanvas.width,
arConf.canvasDimensions.sampleCanvas.height,
false,
);
}
}
// Note that subtitle check should reset aspect ratio outright, regardless of what other tests revealed.
// Also note that subtitle check should run on newest aspect ratio data, rather than lag one frame behind
// But implementation details are something for future Tam to figure out
// If forceFullRecheck is set, then 'not letterbox' should always force-reset the aspect ratio
// (as aspect ratio may have been set manually while autodetection was off)
2025-04-05 01:31:06 +02:00
// If debugging is enable,
this.canvasStore.debug?.drawBuffer(imageData);
2025-10-12 22:03:55 +02:00
processUpdate:
{
if (this.testResults.letterboxOrientation === LetterboxOrientation.NotLetterbox) {
2025-04-21 22:24:32 +02:00
// console.warn('DETECTED NOT LETTERBOX! (resetting)')
2025-04-15 18:51:34 +02:00
this.timer.arChanged();
2025-10-12 22:03:55 +02:00
this.updateAspectRatio(this.defaultAr, {forceReset: true});
this.testResults.activeLetterbox.width = 0;
this.testResults.activeLetterbox.offset = 0;
this.testResults.activeLetterbox.orientation = LetterboxOrientation.NotLetterbox;
2025-10-12 22:03:55 +02:00
break processUpdate;
}
2025-10-13 17:35:37 +02:00
if (this.testResults.subtitleDetected && arConf.subtitles.subtitleCropMode !== AardSubtitleCropMode.CropSubtitles) {
2025-10-12 22:03:55 +02:00
if (arConf.subtitles.subtitleCropMode === AardSubtitleCropMode.ResetAR) {
this.updateAspectRatio(this.defaultAr, {forceReset: true});
this.testResults.activeLetterbox.width = 0;
this.testResults.activeLetterbox.offset = 0;
this.testResults.activeLetterbox.orientation = LetterboxOrientation.NotLetterbox;
2025-10-12 22:03:55 +02:00
this.timers.pauseUntil = Date.now() + arConf.subtitles.resumeAfter;
} else if (arConf.subtitles.subtitleCropMode === AardSubtitleCropMode.ResetAndDisable) {
this.updateAspectRatio(this.defaultAr, {forceReset: true});
this.testResults.activeLetterbox.width = 0;
this.testResults.activeLetterbox.offset = 0;
this.testResults.activeLetterbox.orientation = LetterboxOrientation.NotLetterbox;
2025-10-12 22:03:55 +02:00
this.status.autoDisabled = true;
}
break processUpdate;
2025-04-05 01:31:06 +02:00
}
2025-04-05 01:31:06 +02:00
// if detection is uncertain, we don't do anything at all (unless if guardline was broken, in which case we reset)
2025-10-13 17:35:37 +02:00
if (this.testResults.aspectRatioUncertain) {
// this.timer.arChanged();
// if (
// this.testResults.aspectRatioCheck.frontCandidate < this.testResults.guardLine.front
// || this.testResults.aspectRatioCheck.backCandidate > this.testResults.guardLine.back
// || this.testResults.aspectRatioCheck.frontCandidate === -1
// || this.testResults.aspectRatioCheck.backCandidate === -1
// ) {
// this.updateAspectRatio(this.defaultAr, {uncertainDetection: true, forceReset: true});
// }
2025-04-05 01:31:06 +02:00
2025-10-12 22:03:55 +02:00
break processUpdate;
2025-04-05 01:31:06 +02:00
}
2025-04-05 01:31:06 +02:00
// TODO: emit debug values if debugging is enabled
this.testResults.isFinished = true;
2025-10-13 17:35:37 +02:00
this.testResults.guardLine.front = this.testResults.aspectRatioCheck.frontCandidate;
this.testResults.guardLine.back = this.testResults.aspectRatioCheck.backCandidate;
// TODO: set flag if subtitles are far enough from edge to avoid getting cropped
const finalAr = this.getAr();
if (finalAr > 0) {
this.updateAspectRatio(finalAr);
this.testResults.activeLetterbox.width = this.testResults.letterboxSize;
this.testResults.activeLetterbox.offset = this.testResults.letterboxOffset;
this.testResults.activeLetterbox.orientation = this.testResults.letterboxOrientation;
} else {
this.testResults.aspectRatioInvalid = true;
this.testResults.aspectRatioInvalidReason = finalAr.toFixed(3);
}
2025-04-05 01:31:06 +02:00
// }
2025-04-05 01:31:06 +02:00
// if we got "no letterbox" OR aspectRatioUpdated
2025-10-12 22:03:55 +02:00
}
if (this.canvasStore.debug) {
2025-04-05 01:31:06 +02:00
// this.canvasStore.debug.drawBuffer(imageData);
2025-04-15 18:51:34 +02:00
this.timer.getAverage();
2025-10-13 17:35:37 +02:00
this.debugConfig?.debugUi?.updateTestResults(this.testResults, this.timers);
}
} catch (e) {
console.warn('[Ultrawidify] Aspect ratio autodetection crashed for some reason.\n\nsome reason:', e);
2025-04-26 23:24:57 +02:00
this.videoData.resizer.setAr({type: AspectRatioType.AutomaticUpdate, ratio: this.defaultAr, variant: this.arVariant});
}
}
private getVideoPlaybackState(): VideoPlaybackState {
try {
if (this.video.ended) {
return VideoPlaybackState.Ended;
} else if (this.video.paused) {
return VideoPlaybackState.Paused;
} else if (this.video.error) {
return VideoPlaybackState.Error;
} else {
return VideoPlaybackState.Playing;
}
} catch (e) {
2025-05-04 02:18:58 +02:00
this.logger.warn('getVideoPlaybackState]', `There was an error while determining video playback state.`, e);
return VideoPlaybackState.Error;
}
}
//#region buffer tests
/**
* Get black level of a given frame. We sample black level on very few
* positions just the corners of the frame. If letterboxing or pillarboxing
* exists, then pixels in the corners of the frame should be the blackest
* it gets.
*
* Sampling pattern are four lines, each shooting from its respective corner.
* Value of 'sample' parameter determines how many pixels along this line we
* are going to sample. Offset means how many pixels of those four lines we
* are going to skip before we start sampling.
*
* x 0 1 ... ... x-1
* y × ------------... ...------------ ×
* 0 | 1 1 |
* 1 | 2 2 |
* : | . . :
* : . .
*
* : . . :
* | . . |
* | 2 2 |
* h-1 | 1 1 |
* × ------------... ...------------ ×
*
*
* IMPORTANT NOTES
* <> imageData is one-dimensional array, so we need to account for that.
* <> blackLevel is the darkest brightest subpixel detected
* <> If image has no crop, then this function WILL NOT get the true black level.
* In that case, we don't get an accurate black level, but we know straight
* away that the image is uncropped. If image is uncropped, we can skip other,
* more expensive tests.
*
* @param imageData array of pixels (4 bytes/fields per pixel)
* @param samples number of samples per corner
* @param width width of the frame
* @param height height of the frame
*/
private getBlackLevelFast(imageData: Uint8Array, samples: number, offset: number, width: number, height: number) {
// there's 4 points for each sample, and 3 components for each of the sampling points.
const pixelValues = new Array<number>(samples * 12);
let pvi = 0;
/**
* We should ensure we are accessing pixels in ordered manner in order to
* take advantage of data locality.
*/
const end = offset + samples;
for (let i = offset; i < end; i++) {
2025-10-12 22:03:55 +02:00
const px_r = (i * ROW_SIZE) + (i * PIXEL_SIZE); // red component starts here
pixelValues[pvi++] = imageData[px_r];
pixelValues[pvi++] = imageData[px_r + 1];
pixelValues[pvi++] = imageData[px_r + 2];
imageData[px_r + 3] = GlDebugType.BlackLevelSample;
2025-10-12 22:03:55 +02:00
const endpx_r = px_r + ROW_SIZE - (i * PIXEL_SIZE * 2) - PIXEL_SIZE; // - twice the offset to mirror the diagonal
pixelValues[pvi++] = imageData[endpx_r];
pixelValues[pvi++] = imageData[endpx_r + 1];
pixelValues[pvi++] = imageData[endpx_r + 2];
imageData[endpx_r + 3] = GlDebugType.BlackLevelSample;
}
// now let's populate the bottom two corners
for (let i = end; i --> offset;) {
const row = height - i - 1; // since first row is 0, last row is height - 1
2025-10-12 22:03:55 +02:00
const px_r = (row * ROW_SIZE) + (i * PIXEL_SIZE);
pixelValues[pvi++] = imageData[px_r];
pixelValues[pvi++] = imageData[px_r + 1];
pixelValues[pvi++] = imageData[px_r + 2];
imageData[px_r + 3] = GlDebugType.BlackLevelSample;
2025-10-12 22:03:55 +02:00
const endpx_r = px_r + (ROW_SIZE) - (i * PIXEL_SIZE * 2) - PIXEL_SIZE; // - twice the offset to mirror the diagonal
pixelValues[pvi++] = imageData[endpx_r];
pixelValues[pvi++] = imageData[endpx_r + 1];
pixelValues[pvi++] = imageData[endpx_r + 2];
imageData[endpx_r + 3] = GlDebugType.BlackLevelSample;
}
let min = 255;
let avg = 0;
let p = 0;
for (let i = 0; i < pixelValues.length; i++) {
p = pixelValues[i];
i++;
if (p < pixelValues[i]) {
p = pixelValues[i];
}
i++;
if (p < pixelValues[i]) {
p = pixelValues[i];
}
avg += p;
if (p < min) {
min = p;
}
}
2025-10-12 22:03:55 +02:00
// While there's 4 bytes / 3 values per pixel, a
// avg only contains highest subpixel ... so we really
// only take one sample per pixel instead of 3/4
avg = avg / samples;
2025-10-12 22:03:55 +02:00
if (avg > this.testResults.blackThreshold) {
this.testResults.letterboxOrientation = LetterboxOrientation.NotLetterbox;
}
// only update black level if not letterbox.
// NOTE: but maybe we could, if blackLevel can only get lower than
// the default value.
2025-10-12 22:03:55 +02:00
if (this.testResults.letterboxOrientation === LetterboxOrientation.NotLetterbox) {
this.testResults.aspectRatioUncertain = false;
2025-10-12 22:03:55 +02:00
}
if (min < this.testResults.blackLevel) {
this.testResults.blackLevel = min;
this.testResults.blackThreshold = min + 16;
}
}
2025-10-12 22:03:55 +02:00
/**
* Checks orientation of black bars.
* @param imageData
* @param width
* @param height
*/
private letterboxOrientationScan(imageData: Uint8Array, width: number, height: number) {
const lastPixelOffset = ROW_SIZE - PIXEL_SIZE;
const imageSize = ROW_SIZE * height;
2025-10-14 18:52:22 +02:00
const xLimit = this.settings.active.aard.letterboxOrientationScan.letterboxLimit;
const yLimit = this.settings.active.aard.letterboxOrientationScan.pillarboxLimit;
2025-10-12 22:03:55 +02:00
let letterbox = true, pillarbox = true;
let xCount = 0, yCount = 0;
// scan top row
for (let i = 0; i < ROW_SIZE; i += PIXEL_SIZE) {
if (
imageData[i ] > this.testResults.blackThreshold
|| imageData[i+1] > this.testResults.blackThreshold
|| imageData[i+2] > this.testResults.blackThreshold
) {
imageData[i + 3] = GlDebugType.LetterboxOrientationScanImageDetection
if (++xCount > xLimit) {
letterbox = false;
break;
}
} else {
imageData[i+3] = GlDebugType.LetterboxOrientationScanTrace
}
}
2025-10-12 22:03:55 +02:00
// scan sides
for (let i = 0; i < imageSize; i += ROW_SIZE) {
const lastPx = i + lastPixelOffset;
// left side
if (
imageData[i ] > this.testResults.blackThreshold
|| imageData[i+1] > this.testResults.blackThreshold
|| imageData[i+2] > this.testResults.blackThreshold
) {
imageData[i+3] = GlDebugType.LetterboxOrientationScanImageDetection;
if (++yCount > yLimit) {
pillarbox = false;
break;
}
} else {
imageData[i+3] = GlDebugType.LetterboxOrientationScanTrace;
}
// right side
if (
imageData[lastPx ] > this.testResults.blackThreshold
|| imageData[lastPx+1] > this.testResults.blackThreshold
|| imageData[lastPx+2] > this.testResults.blackThreshold
) {
imageData[lastPx+3] = GlDebugType.LetterboxOrientationScanImageDetection;
if (++yCount > yLimit) {
pillarbox = false;
break;
}
} else {
imageData[lastPx+3] = GlDebugType.LetterboxOrientationScanTrace;
}
}
// scan bottom row
if (letterbox) {
for (let i = ROW_SIZE * (height - 1); i < imageSize; i += PIXEL_SIZE) {
if ( imageData[i ] > this.testResults.blackThreshold
|| imageData[i+1] > this.testResults.blackThreshold
|| imageData[i+2] > this.testResults.blackThreshold
) {
imageData[i + 3] = GlDebugType.LetterboxOrientationScanImageDetection
if (++xCount > xLimit) {
letterbox = false;
break;
}
} else {
imageData[i+3] = GlDebugType.LetterboxOrientationScanTrace
}
}
}
// determine result
if (letterbox && pillarbox) {
this.testResults.letterboxOrientation = LetterboxOrientation.Both;
} else if (letterbox) {
this.testResults.letterboxOrientation = LetterboxOrientation.Letterbox;
this.testResults.lastValidLetterboxOrientation = LetterboxOrientation.Letterbox;
} else if (pillarbox) {
this.testResults.letterboxOrientation = LetterboxOrientation.Pillarbox;
this.testResults.lastValidLetterboxOrientation = LetterboxOrientation.Pillarbox;
} else {
this.testResults.letterboxOrientation = LetterboxOrientation.NotLetterbox;
}
}
2025-10-13 17:35:37 +02:00
/**
* Updates letterbox edge (updates imageLine and guardLine)
* @param crossDimension height of the sample frame (or width, if pillarbox)
* @param topCandidate First line of image data on the top of the frame
* @param bottomCandidate First line with image data on the bottom of the frame
* @returns
*/
private updateLetterboxEdgeCandidates(crossDimension: number, topCandidate: number, bottomCandidate: number) {
// if new topCandidate or bottomCandidate aren't valid,
// use existing value.
if (topCandidate < 0) {
topCandidate = this.testResults.aspectRatioCheck.frontCandidate;
}
if (bottomCandidate < 0) {
bottomCandidate = this.testResults.aspectRatioCheck.backCandidate;
}
2025-10-13 17:35:37 +02:00
const bottomDistance = (crossDimension - bottomCandidate);
2025-10-14 18:52:22 +02:00
const maxOffset = ~~(crossDimension * this.settings.active.aard.edgeDetection.maxLetterboxOffset);
2025-10-13 17:35:37 +02:00
const diff = Math.abs(topCandidate - bottomDistance);
const candidateAvg = ~~((topCandidate + bottomDistance) * 0.5);
this.testResults.aspectRatioCheck.frontCandidate = topCandidate;
this.testResults.aspectRatioCheck.backCandidate = bottomCandidate;
if (diff > maxOffset) {
this.testResults.aspectRatioUncertain = true;
this.testResults.aspectRatioUncertainReason = AardUncertainReason.LetterboxNotCenteredEnough;
}
this.testResults.letterboxSize = candidateAvg;
this.testResults.letterboxOffset = diff;
if (this.testResults.letterboxOrientation === LetterboxOrientation.Letterbox && this.testResults.subtitleDetected) {
const top = this.testResults.subtitleScan.regions.top.firstSubtitle === -1 ? topCandidate : Math.min(topCandidate, this.testResults.subtitleScan.regions.top.firstSubtitle);
const bottom = Math.max(bottomCandidate, this.testResults.subtitleScan.regions.bottom.firstSubtitle);
this.testResults.letterboxSizeWithSubtitles = ~~((top + (crossDimension - bottomCandidate)) * 0.5);
}
2025-10-13 17:35:37 +02:00
}
/**
2025-10-13 17:35:37 +02:00
* Scans for subtitles
* @param imageData
* @param width
* @param height
2025-10-13 17:35:37 +02:00
* @returns
*/
2025-10-13 17:35:37 +02:00
private subtitleScan(imageData: Uint8Array, width: number, height: number, skipAdvancedScan: boolean) {
2025-10-14 18:52:22 +02:00
const scanConf = this.settings.active.aard.subtitles;
2025-10-13 17:35:37 +02:00
this.testResults.subtitleDetected = false;
2025-10-13 17:35:37 +02:00
// SubtitleScanResult Regions
const ssrRegions = this.testResults.subtitleScan.regions;
const halfHeight = Math.floor(height / 2);
2024-12-31 03:15:10 +01:00
2025-10-13 17:35:37 +02:00
resetSubtitleScanResults(this.testResults);
2025-10-13 17:35:37 +02:00
this.subtitleScanRegionIterative(
imageData, height,
2, halfHeight,
scanConf.refiningScanSpacing, scanConf.minDetections,
ssrRegions.top,
)
2024-12-31 03:15:10 +01:00
2025-10-13 17:35:37 +02:00
this.subtitleScanRegionIterative(
imageData, height,
height - 3, halfHeight,
-scanConf.refiningScanSpacing, scanConf.minDetections,
ssrRegions.bottom,
)
if (ssrRegions.top.uncertain || ssrRegions.bottom.uncertain) {
this.testResults.aspectRatioUncertain = true;
} else {
2025-10-13 17:35:37 +02:00
this.testResults.aspectRatioUncertain = false;
}
2025-10-12 22:03:55 +02:00
// 1. updateLetterboxEdgeCandidates runs regardless of whether we detected subtitles or not.
// 2. it's also not affected by whether subtitleDetected is set
// 3. we actually need to only reset letterbox if subtitle is actually outside of the crop area
2025-10-13 17:35:37 +02:00
this.updateLetterboxEdgeCandidates(
height,
ssrRegions.top.firstImage,
ssrRegions.bottom.firstImage
);
2025-10-12 22:03:55 +02:00
// we can only do this in actual letterbox
if (this.testResults.activeLetterbox.orientation === LetterboxOrientation.Letterbox) {
// we only reset letterbox if letters are outside the video area, otherwise we risk
// getting whacked by credits, ppt youtubers, and other shit like that
const borderTop = this.testResults.activeLetterbox.width;
const borderBottom = this.settings.active.aard.canvasDimensions.sampleCanvas.height - this.testResults.activeLetterbox.width;
if (
(ssrRegions.top.firstSubtitle !== -1 && ssrRegions.top.firstSubtitle < borderTop)
|| (ssrRegions.bottom.firstSubtitle !== -1 && ssrRegions.bottom.firstSubtitle > borderBottom)
) {
this.testResults.subtitleDetected = true;
}
}
2025-10-13 17:35:37 +02:00
this.timer.current.subtitleScan = performance.now() - this.timer.current.start;
2025-10-12 22:03:55 +02:00
}
/**
2025-10-13 17:35:37 +02:00
* Scans region of video frame for presence of subtitles
* @param imageData
2025-10-13 17:35:37 +02:00
* @param startRow
* @param endRow
* @param minDetections
* @param results
*/
2025-10-13 17:35:37 +02:00
private subtitleScanRegionLinear(
imageData: Uint8Array,
height: number,
startRow: number,
endRow: number,
scanSpacing: number,
minDetections: number,
results: AardTestResult_SubtitleRegion,
) {
results.uncertain = false;
2024-10-15 17:38:04 +02:00
2025-10-14 18:52:22 +02:00
const scanConf = this.settings.active.aard.subtitles;
const arConf = this.settings.active.aard;
2025-10-12 22:03:55 +02:00
2025-10-13 17:35:37 +02:00
let letterCount, imageSegmentCount, potentialFadedLetterCount, potentialFadedLetterCountInvalidated, nonGradientPixelCount, letterSize, imageSize, imageSegmentSize, imageWeightedSize, segmentWeights, imageSegmentAlignment, imageSegmentAlignmentSamples,
isOnLetter, isOnImage, isBlank,
gradientRowOffset_before, gradientRowOffset_after;
let rowStart, rowEnd, rowMid, rowGTA, rowGTB; // GT = gradient test
let imageConfirmPass = false, subtitleConfirmPass = false;
2024-10-15 17:38:04 +02:00
2025-10-14 18:52:22 +02:00
let outerIteration = 0;
2025-10-12 22:03:55 +02:00
2025-10-13 17:35:37 +02:00
const rowMargin = Math.floor(scanConf.scanMargin * ROW_SIZE);
const imageThreshold = Math.floor((ROW_SIZE - (rowMargin * 2)) * arConf.edgeDetection.minValidImage * PIXEL_SIZE_FRACTION);
2024-10-15 17:38:04 +02:00
2025-10-13 17:35:37 +02:00
// search in top letterbox
outerLoop:
for (
let searchRow = startRow;
(scanSpacing > 0 && searchRow < endRow) || (scanSpacing < 0 && searchRow > endRow);
searchRow += scanSpacing
) {
2025-10-14 18:52:22 +02:00
if (++outerIteration > height) {
// console.warn('[ultrawidify|aard::subtitleScanRegionLinear] — scan got stuck in an infinite loop. This shouldn\'t happen.');
2025-10-14 18:52:22 +02:00
results.uncertain;
break outerLoop;
}
2024-10-15 17:38:04 +02:00
2025-10-13 17:35:37 +02:00
letterCount = 0;
potentialFadedLetterCount = 0;
imageSegmentCount = 0;
imageSegmentSize = 0;
nonGradientPixelCount = 0;
2024-10-15 17:38:04 +02:00
2025-10-13 17:35:37 +02:00
imageSize = 0;
imageWeightedSize = 0;
segmentWeights = 0;
2024-10-15 17:38:04 +02:00
2025-10-13 17:35:37 +02:00
letterSize = 0;
isOnLetter = false;
isOnImage = false;
isBlank = true;
potentialFadedLetterCountInvalidated = false;
2024-10-15 17:38:04 +02:00
2025-10-13 17:35:37 +02:00
imageSegmentAlignment = 0;
imageSegmentAlignmentSamples = 0;
2024-10-15 17:38:04 +02:00
2025-10-13 17:35:37 +02:00
// Scan region is centered,
rowStart = (searchRow * ROW_SIZE) + rowMargin;
2025-10-12 22:03:55 +02:00
2025-10-13 17:35:37 +02:00
if (scanSpacing > 0) {
gradientRowOffset_before = (Math.max(searchRow - 2, 0) * ROW_SIZE);
gradientRowOffset_after = (Math.min(searchRow + 2, height) * ROW_SIZE);
2025-10-12 22:03:55 +02:00
} else {
2025-10-13 17:35:37 +02:00
gradientRowOffset_before = (Math.max(searchRow - 2, 0) * ROW_SIZE);
gradientRowOffset_after = (Math.min(searchRow + 2, height) * ROW_SIZE);
2024-10-15 17:38:04 +02:00
}
2025-10-13 17:35:37 +02:00
// exact row doesn't matter ... unless scanMargin is 0
rowEnd = ((searchRow + 1) * ROW_SIZE) - rowMargin;
rowMid = (rowStart + rowEnd) * 0.5;
2025-10-13 17:35:37 +02:00
const resetSubtitlePass =() => {
if (subtitleConfirmPass) {
subtitleConfirmPass = false;
searchRow -= (scanSpacing > 0 ? + 1 : -1);
}
}
2025-10-12 22:03:55 +02:00
2025-10-13 17:35:37 +02:00
const updateImage = (candidate: number) => {
if (scanSpacing > 0) {
if (results.firstImage === -1 || candidate < results.firstImage) {
results.firstImage = candidate;
}
} else {
if (results.firstImage === -1 || candidate > results.firstImage) {
results.firstImage = candidate;
}
}
}
2024-10-19 16:04:20 +02:00
2025-10-13 17:35:37 +02:00
/**
* This function can break or return early only in the following situations:
*
* * we have enough letters to detect subtitles continue outerLoop
* * Single "letter" is too wide immediate return, as we found where the image starts
*
* Other instances require a bit more complex analysis.
*/
2025-10-12 22:03:55 +02:00
2025-10-13 17:35:37 +02:00
while (rowStart < rowEnd) {
const r = imageData[rowStart], g = imageData[rowStart + 1], b = imageData[rowStart + 2];
2025-10-12 22:03:55 +02:00
2025-10-13 17:35:37 +02:00
const on = r > scanConf.subtitleSubpixelThresholdOn
|| g > scanConf.subtitleSubpixelThresholdOn
|| b > scanConf.subtitleSubpixelThresholdOn;
const off = r < scanConf.subtitleSubpixelThresholdOff
&& g < scanConf.subtitleSubpixelThresholdOff
&& b < scanConf.subtitleSubpixelThresholdOff;
2025-10-12 22:03:55 +02:00
2025-10-13 17:35:37 +02:00
if (off) {
imageData[rowStart + 3] = GlDebugType.SubtitleThresholdOff;
2024-10-19 16:04:20 +02:00
2025-10-13 17:35:37 +02:00
// if isOnLetter was set, that means we've just concluded a letter segment
if (isOnLetter) {
letterCount++;
2024-10-19 16:04:20 +02:00
2025-10-13 17:35:37 +02:00
if (letterCount > minDetections) {
if (results.firstSubtitle === -1) {
results.firstSubtitle = searchRow;
2024-10-19 16:04:20 +02:00
2025-10-13 17:35:37 +02:00
// if detecting subtitles only resets AR, we can return immediately
if (scanConf.subtitleCropMode !== AardSubtitleCropMode.CropSubtitles) {
break outerLoop;
}
}
results.lastSubtitle = searchRow;
isBlank = false;
2024-10-19 16:04:20 +02:00
2025-10-13 17:35:37 +02:00
imageConfirmPass = false;
continue outerLoop;
}
2024-10-19 16:04:20 +02:00
}
2025-10-13 17:35:37 +02:00
if (isOnImage) {
if (imageSegmentSize < scanConf.maxValidLetter) {
potentialFadedLetterCount++;
// track potential letter alignment
imageSegmentAlignment += (rowStart - rowMid) * imageSegmentSize * PIXEL_SIZE_FRACTION;
imageSegmentAlignmentSamples += imageSegmentSize;
} else {
potentialFadedLetterCountInvalidated = true;
}
if (imageSegmentSize > arConf.edgeDetection.minEdgeSegmentSize) {
imageSegmentCount++;
imageWeightedSize += imageSegmentSize * imageSegmentSize; // longer segments should have bigger weight
segmentWeights += imageSegmentSize;
imageSegmentSize = 0;
}
2024-10-19 16:04:20 +02:00
}
2025-10-13 17:35:37 +02:00
isOnLetter = false;
isOnImage = false;
letterSize = 0;
imageSegmentSize = 0;
} else {
imageData[rowStart + 3] = GlDebugType.SubtitleThresholdNone;
isOnImage = true;
isBlank = false;
imageSegmentSize++;
imageSize++;
// let's see if we're accidentally detecting gradient.
// We only need to run these tests when pixels are dark.
// We keep track of number of pixels that pass the test, or don't
// need the test to begin with.
if (!on && (
imageData[rowStart ] < arConf.edgeDetection.gradientThreshold
&& imageData[rowStart + 1] < arConf.edgeDetection.gradientThreshold
&& imageData[rowStart + 2] < arConf.edgeDetection.gradientThreshold
)) {
rowGTB = rowStart - gradientRowOffset_before;
rowGTA = rowStart + gradientRowOffset_after;
// if true, then gradient.
// The first row gives technically incorrect answers for pixels directly under subtitles, but since
// "nonGradientPixelCount" is a shorthand for "we're relatively confident in this detection", potentially
// mistaking non-black pixels under subtitles for gradient isn't too problematic
if ( (imageData[rowStart ] - imageData[rowGTB ]) < arConf.edgeDetection.gradientTestMinDelta
&& (imageData[rowStart + 1] - imageData[rowGTB + 1]) < arConf.edgeDetection.gradientTestMinDelta
&& (imageData[rowStart + 2] - imageData[rowGTB + 2]) < arConf.edgeDetection.gradientTestMinDelta
&& imageData[rowGTA ] - imageData[rowStart ] > arConf.edgeDetection.gradientTestMinDeltaAfter
&& imageData[rowGTA + 1] - imageData[rowStart + 1] > arConf.edgeDetection.gradientTestMinDeltaAfter
&& imageData[rowGTA + 2] - imageData[rowStart + 2] > arConf.edgeDetection.gradientTestMinDeltaAfter
&& imageData[rowGTA ] - imageData[rowStart ] < arConf.edgeDetection.gradientTestMaxDeltaAfter
&& imageData[rowGTA + 1] - imageData[rowStart + 1] < arConf.edgeDetection.gradientTestMaxDeltaAfter
&& imageData[rowGTA + 2] - imageData[rowStart + 2] < arConf.edgeDetection.gradientTestMaxDeltaAfter
) {
// we do nothing (at this moment)
} else {
nonGradientPixelCount++;
}
} else {
nonGradientPixelCount++;
}
2024-10-19 16:04:20 +02:00
}
2025-10-13 17:35:37 +02:00
if (on) { // used to detect subtitles specifically
imageData[rowStart + 3] = GlDebugType.SubtitleThresholdOn;
isOnLetter = true;
letterSize++;
2024-10-19 16:04:20 +02:00
2025-10-13 17:35:37 +02:00
// bail on invalid letter sizes — this means we're seeing image.
// in this case, we do not need image confirmation step
if (letterSize > scanConf.maxValidLetter) {
updateImage(searchRow);
isBlank = false;
2024-10-19 16:04:20 +02:00
2025-10-13 17:35:37 +02:00
return;
2024-10-19 16:04:20 +02:00
}
}
2025-10-13 17:35:37 +02:00
rowStart += PIXEL_SIZE;
2024-10-19 16:04:20 +02:00
}
2025-10-12 22:03:55 +02:00
2025-10-13 17:35:37 +02:00
// we need to do this once more, otherwise imageSegmentCount can be 0 & bugs happen
if (isOnImage) {
if (imageSegmentSize < scanConf.maxValidLetter) {
potentialFadedLetterCount++;
// track potential letter alignment
imageSegmentAlignment += (rowStart - rowMid) * imageSegmentSize * PIXEL_SIZE_FRACTION;
imageSegmentAlignmentSamples += imageSegmentSize;
} else {
potentialFadedLetterCountInvalidated = true;
2025-10-12 22:03:55 +02:00
}
2025-10-13 17:35:37 +02:00
if (imageSegmentSize > arConf.edgeDetection.minEdgeSegmentSize) {
imageSegmentCount++;
imageWeightedSize += imageSegmentSize * imageSegmentSize; // longer segments should have bigger weight
segmentWeights += imageSegmentSize;
imageSegmentSize = 0;
}
2025-10-12 22:03:55 +02:00
}
2025-10-13 17:35:37 +02:00
if (!imageSegmentCount || isBlank) {
isBlank = true;
imageConfirmPass = false;
if (results.firstBlank === -1) {
results.firstBlank = searchRow;
2025-10-12 22:03:55 +02:00
}
2025-10-13 17:35:37 +02:00
results.lastBlank = searchRow;
2025-10-12 22:03:55 +02:00
2025-10-13 17:35:37 +02:00
resetSubtitlePass();
continue outerLoop;
2025-10-12 22:03:55 +02:00
}
2025-10-13 17:35:37 +02:00
const averageImageSegmentSize = segmentWeights > 0 ? imageWeightedSize / segmentWeights : 0;
const gradientDetectionFrequency = 1 - (nonGradientPixelCount / imageSize);
2025-10-12 22:03:55 +02:00
2025-10-13 17:35:37 +02:00
// That's probably a subtitle as well. If subs are fading in and out,
// then there's a good chance that they won't meet the "yes, this is a letter" threshold.
if (
!potentialFadedLetterCountInvalidated
&& potentialFadedLetterCount > scanConf.minDetections
// FOR SOME REASON THIS CONDITION CAUSES EVERYTHING TO HANG:
// && (imageSegmentAlignmentSamples && imageSegmentAlignment && Math.abs(imageSegmentAlignment / imageSegmentAlignmentSamples) > scanConf.maxPotentialSubtitleMisalignment)
&& averageImageSegmentSize < scanConf.maxValidLetter
) {
if (subtitleConfirmPass) {
if (results.firstSubtitle === -1) {
results.firstSubtitle = searchRow;
2024-10-19 16:04:20 +02:00
2025-10-13 17:35:37 +02:00
// if detecting subtitles only resets AR, we can return immediately
if (scanConf.subtitleCropMode !== AardSubtitleCropMode.CropSubtitles) {
break outerLoop;
}
}
results.lastSubtitle = searchRow;
isBlank = false;
resetSubtitlePass();
2024-10-19 16:04:20 +02:00
}
2025-10-13 17:35:37 +02:00
subtitleConfirmPass = true;
searchRow -= scanSpacing + (scanSpacing > 0 ? - 1 : 1);
continue outerLoop;
2024-10-19 16:04:20 +02:00
}
2025-10-13 17:35:37 +02:00
// If we are here, subtitles weren't confirmed
resetSubtitlePass();
2025-04-15 18:51:34 +02:00
2024-10-19 16:04:20 +02:00
2025-10-13 17:35:37 +02:00
// If we detect gradient, that's instant fail.
// We still save uncertain detection to firstImage, because iterative scan uses that
// in order to determine which region to scan further
if (gradientDetectionFrequency > arConf.edgeDetection.gradientThreshold) {
results.uncertain = true;
updateImage(searchRow);
break outerLoop;
2024-10-19 16:04:20 +02:00
}
2025-10-13 17:35:37 +02:00
// Cases which require confirmation
2025-10-12 22:03:55 +02:00
if (
2025-10-13 17:35:37 +02:00
imageSegmentCount > arConf.edgeDetection.maxEdgeSegments // we need to confirm if there's too many segments
|| averageImageSegmentSize < arConf.edgeDetection.averageEdgeThreshold // we also need to confirm if segments are too small
2025-10-12 22:03:55 +02:00
) {
2025-10-13 17:35:37 +02:00
if (imageConfirmPass) {
results.uncertain = true;
updateImage(searchRow - 1);
break outerLoop;
2025-10-12 22:03:55 +02:00
}
2025-10-13 17:35:37 +02:00
imageConfirmPass = true;
// imageConfirmPass must happen on the next row, but it's possible that we aren't
// checking row-by-row. Hence, we need to modify our scan a bit
searchRow -= scanSpacing + (scanSpacing > 0 ? - 1 : 1);
2025-10-12 22:03:55 +02:00
2025-10-13 17:35:37 +02:00
continue outerLoop;
2025-10-12 22:03:55 +02:00
}
2025-10-13 17:35:37 +02:00
if (averageImageSegmentSize > arConf.edgeDetection.averageEdgeThreshold || imageSize > imageThreshold) {
updateImage(searchRow);
break outerLoop;
2025-10-12 22:03:55 +02:00
}
2025-10-13 17:35:37 +02:00
// we can only reach this far if we detected image
if (imageConfirmPass) {
updateImage(searchRow - 1);
} else {
updateImage(searchRow);
2025-10-12 22:03:55 +02:00
}
2025-10-13 17:35:37 +02:00
break outerLoop;
} // end of outer loop
2025-10-12 22:03:55 +02:00
}
/**
* Tries to determine letterbox through subtitles
* @param imageData
* @param startRow
* @param endRow
2025-10-13 17:35:37 +02:00
* @param ROW_SIZE
2025-10-12 22:03:55 +02:00
* @param scanSpacing
* @param minDetections
* @param results
* @param ssrRegionName
* @returns
*/
private subtitleScanRegionIterative(
imageData: Uint8Array,
2025-10-13 17:35:37 +02:00
height: number,
2025-10-12 22:03:55 +02:00
startRow: number,
endRow: number,
scanSpacing: number,
minDetections: number,
results: AardTestResult_SubtitleRegion,
): boolean {
while (true) {
if (scanSpacing > -1 && scanSpacing < 1) {
break;
}
this.subtitleScanRegionLinear(
imageData, height,
startRow, endRow,
scanSpacing, minDetections, results
);
if (results.firstImage === -1) {
return false;
}
2025-10-13 17:35:37 +02:00
// We need to ensure small amount of overlap, in case we landed on a sus row
2025-10-12 22:03:55 +02:00
if (scanSpacing > 0) {
2025-10-13 17:35:37 +02:00
startRow = Math.max(results.firstImage - Math.floor(scanSpacing * 2), 0);
endRow = Math.min(results.firstImage + Math.floor(scanSpacing * 2), height - 1);
2025-10-12 22:03:55 +02:00
} else {
2025-10-13 17:35:37 +02:00
startRow = Math.min(results.firstImage + Math.floor(-scanSpacing * 2), height - 1);
endRow = Math.max(results.firstImage - Math.floor(-scanSpacing * 2), 0);
2025-10-12 22:03:55 +02:00
}
scanSpacing = scanSpacing / 2;
}
return true;
}
2024-12-31 02:50:33 +01:00
/**
* Updates aspect ratio if new aspect ratio is different enough from the old one
*/
2025-10-12 22:03:55 +02:00
private updateAspectRatio(ar: number, options?: {uncertainDetection?: boolean, forceReset?: boolean}) {
2024-12-31 02:50:33 +01:00
// Calculate difference between two ratios
2025-10-12 22:03:55 +02:00
// We need to detect updates even if subtitles are detected — we just don't trigger
// the actual aspect ratio change if everything is paused.
if (this.timers.pauseUntil > Date.now() && !options?.forceReset) {
return false;
}
2024-12-31 02:50:33 +01:00
const maxRatio = Math.max(ar, this.testResults.activeAspectRatio);
const diff = Math.abs(ar - this.testResults.activeAspectRatio);
2025-10-14 18:52:22 +02:00
if ((diff / maxRatio) > this.settings.active.aard.allowedArVariance || options?.forceReset) {
2024-12-31 02:50:33 +01:00
this.videoData.resizer.updateAr({
type: AspectRatioType.AutomaticUpdate,
2025-10-12 22:03:55 +02:00
ratio: ar,
2025-04-26 23:24:57 +02:00
offset: this.testResults.letterboxOffset,
variant: this.arVariant
2024-12-31 02:50:33 +01:00
});
this.testResults.activeAspectRatio = ar;
if (!options?.uncertainDetection) {
2025-10-14 18:52:22 +02:00
if (this.settings.active.aard.autoDisable.onFirstChange) {
this.status.autoDisabled = true;
}
2025-10-14 18:52:22 +02:00
if (this.settings.active.aard.autoDisable.ifNotChanged) {
this.timers.autoDisableAt = Date.now() + this.settings.active.aard.autoDisable.ifNotChangedTimeout;
}
}
2025-10-12 22:03:55 +02:00
this.testResults.aspectRatioUpdated = true;
2024-12-31 02:50:33 +01:00
}
}
/**
* Calculates video's current aspect ratio based on data in testResults.
* @returns
*/
private getAr(): number {
const fileAr = this.video.videoWidth / this.video.videoHeight;
const canvasAr = this.canvasStore.main.width / this.canvasStore.main.height;
2025-04-10 00:46:44 +02:00
const compensatedWidth = fileAr === canvasAr ? this.canvasStore.main.width : this.video.videoWidth * this.canvasStore.main.height / (this.video.videoHeight);
// console.log(`
// ———— ASPECT RATIO CALCULATION: —————
// canvas size: ${this.canvasStore.main.width} x ${this.canvasStore.main.height} (1:${this.canvasStore.main.width / this.canvasStore.main.height})
// file size: ${this.video.videoWidth} x ${this.video.videoHeight} (1:${this.video.videoWidth / this.video.videoHeight})
// compensated size: ${compensatedWidth} x ${this.canvasStore.main.height} (1:${compensatedWidth / this.canvasStore.main.height})
// letterbox height: ${this.testResults.letterboxWidth}
// net video height: ${this.canvasStore.main.height - (this.testResults.letterboxWidth * 2)}
// calculated aspect ratio -----
// ${compensatedWidth} ${compensatedWidth} ${compensatedWidth}
// ——————————————— = —————————————— = —————— = ${compensatedWidth / (this.canvasStore.main.height - (this.testResults.letterboxWidth * 2))}
// ${this.canvasStore.main.height} - 2 x ${this.testResults.letterboxWidth} ${this.canvasStore.main.height} - ${2 * this.testResults.letterboxWidth} ${this.canvasStore.main.height - (this.testResults.letterboxWidth * 2)}
// `);
2025-10-12 22:03:55 +02:00
if (this.testResults.letterboxOrientation === LetterboxOrientation.Pillarbox) {
const compensationFactor = compensatedWidth / this.canvasStore.main.width;
const pillarboxCompensated = (this.testResults.letterboxSize * 2 * compensationFactor);
2025-10-12 22:03:55 +02:00
return (compensatedWidth - pillarboxCompensated) / this.canvasStore.main.height;
} else {
const heightWithoutLetterbox = this.canvasStore.main.height - (this.testResults.letterboxSize * 2);
// TODO: set flag if subtitles are far enough from edge to avoid getting cropped
// if (this.testResults.subtitleDetected) {
// const hwlWithSubtitles = this.canvasStore.main.height - (this.testResults.letterboxSizeWithSubtitles * 2)
// const subtitleRatio = compensatedWidth / hwlWithSubtitles;
// }
2025-10-12 22:03:55 +02:00
return compensatedWidth / heightWithoutLetterbox;
}
}
2024-10-19 16:04:20 +02:00
//#endregion
}