feat: add motion policy and adaptive quality
This commit is contained in:
228
src/journey/adaptive-quality.ts
Normal file
228
src/journey/adaptive-quality.ts
Normal file
@@ -0,0 +1,228 @@
|
||||
import type { QualityTier } from '@/journey/motion-policy';
|
||||
|
||||
export interface QualityDecision {
|
||||
previous: QualityTier;
|
||||
next: QualityTier;
|
||||
reason: 'catastrophic-frame' | 'p95-window' | 'two-bad-low-windows' | 'maximum-change';
|
||||
}
|
||||
|
||||
export interface AdaptiveQualitySnapshot {
|
||||
maximum: QualityTier;
|
||||
actual: QualityTier;
|
||||
epoch: number | null;
|
||||
coolingDownUntil: number | null;
|
||||
eligibleSamples: number;
|
||||
consecutiveBadLowWindows: number;
|
||||
}
|
||||
|
||||
export interface AdaptiveQualityController {
|
||||
enterRunningEpoch(now: number): void;
|
||||
leaveRunningEpoch(): void;
|
||||
sampleFrame(start: number, end: number): QualityDecision | null;
|
||||
setMaximum(tier: QualityTier, reason: 'resize' | 'manual'): QualityDecision | null;
|
||||
snapshot(): AdaptiveQualitySnapshot;
|
||||
}
|
||||
|
||||
const WARM_UP_MS = 2000;
|
||||
const WINDOW_MS = 5000;
|
||||
const WINDOW_SAMPLES = 120;
|
||||
const CATASTROPHIC_MS = 250;
|
||||
const COOLDOWN_MS = 30000;
|
||||
|
||||
const TIER_RANK: Readonly<Record<QualityTier, number>> = {
|
||||
static: 0,
|
||||
low: 1,
|
||||
medium: 2,
|
||||
high: 3,
|
||||
};
|
||||
|
||||
export function createAdaptiveQualityController(initialMaximum: QualityTier): AdaptiveQualityController {
|
||||
let maximum = initialMaximum;
|
||||
let actual = initialMaximum;
|
||||
let epoch: number | null = null;
|
||||
let nextEpoch = 1;
|
||||
let epochStartedAt = 0;
|
||||
let lastEpochTime = 0;
|
||||
let samplingReadyAt = 0;
|
||||
let coolingDownUntil: number | null = null;
|
||||
let pausedCooldownRemaining = 0;
|
||||
let windowStartedAt: number | null = null;
|
||||
let samples: number[] = [];
|
||||
let consecutiveBadLowWindows = 0;
|
||||
|
||||
const resetWindow = (): void => {
|
||||
windowStartedAt = null;
|
||||
samples = [];
|
||||
};
|
||||
|
||||
const leaveRunningEpoch = (): void => {
|
||||
if (epoch === null) {
|
||||
return;
|
||||
}
|
||||
if (coolingDownUntil !== null) {
|
||||
pausedCooldownRemaining = Math.max(0, coolingDownUntil - lastEpochTime);
|
||||
}
|
||||
epoch = null;
|
||||
coolingDownUntil = null;
|
||||
resetWindow();
|
||||
};
|
||||
|
||||
const enterRunningEpoch = (now: number): void => {
|
||||
if (!Number.isFinite(now)) {
|
||||
leaveRunningEpoch();
|
||||
return;
|
||||
}
|
||||
leaveRunningEpoch();
|
||||
epoch = nextEpoch;
|
||||
nextEpoch += 1;
|
||||
epochStartedAt = now;
|
||||
lastEpochTime = now;
|
||||
if (pausedCooldownRemaining > 0) {
|
||||
coolingDownUntil = now + pausedCooldownRemaining;
|
||||
samplingReadyAt = coolingDownUntil + WARM_UP_MS;
|
||||
pausedCooldownRemaining = 0;
|
||||
} else {
|
||||
coolingDownUntil = null;
|
||||
samplingReadyAt = now + WARM_UP_MS;
|
||||
}
|
||||
resetWindow();
|
||||
};
|
||||
|
||||
const beginCooldown = (now: number): void => {
|
||||
coolingDownUntil = now + COOLDOWN_MS;
|
||||
samplingReadyAt = coolingDownUntil + WARM_UP_MS;
|
||||
pausedCooldownRemaining = 0;
|
||||
consecutiveBadLowWindows = 0;
|
||||
resetWindow();
|
||||
};
|
||||
|
||||
const downgrade = (
|
||||
now: number,
|
||||
reason: QualityDecision['reason'],
|
||||
): QualityDecision | null => {
|
||||
const next = nextLowerTier(actual);
|
||||
if (next === null) {
|
||||
resetWindow();
|
||||
return null;
|
||||
}
|
||||
const decision: QualityDecision = { previous: actual, next, reason };
|
||||
actual = next;
|
||||
beginCooldown(now);
|
||||
return decision;
|
||||
};
|
||||
|
||||
const sampleFrame = (start: number, end: number): QualityDecision | null => {
|
||||
if (
|
||||
epoch === null
|
||||
|| !Number.isFinite(start)
|
||||
|| !Number.isFinite(end)
|
||||
|| end <= start
|
||||
|| start < epochStartedAt
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
lastEpochTime = Math.max(lastEpochTime, end);
|
||||
if (coolingDownUntil !== null) {
|
||||
if (end < coolingDownUntil) {
|
||||
return null;
|
||||
}
|
||||
coolingDownUntil = null;
|
||||
}
|
||||
if (start < samplingReadyAt || actual === 'static') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const interval = end - start;
|
||||
if (interval > CATASTROPHIC_MS) {
|
||||
return downgrade(end, 'catastrophic-frame');
|
||||
}
|
||||
|
||||
if (windowStartedAt === null) {
|
||||
windowStartedAt = samplingReadyAt;
|
||||
}
|
||||
samples.push(interval);
|
||||
if (end - windowStartedAt < WINDOW_MS || samples.length < WINDOW_SAMPLES) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const p95 = nearestRankP95(samples);
|
||||
resetWindow();
|
||||
samplingReadyAt = end;
|
||||
|
||||
if (actual === 'high' && p95 > 20) {
|
||||
return downgrade(end, 'p95-window');
|
||||
}
|
||||
if (actual === 'medium' && p95 > 25) {
|
||||
return downgrade(end, 'p95-window');
|
||||
}
|
||||
if (actual === 'low') {
|
||||
consecutiveBadLowWindows = p95 > 40 ? consecutiveBadLowWindows + 1 : 0;
|
||||
if (consecutiveBadLowWindows >= 2) {
|
||||
return downgrade(end, 'two-bad-low-windows');
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const setMaximum = (
|
||||
tier: QualityTier,
|
||||
reason: 'resize' | 'manual',
|
||||
): QualityDecision | null => {
|
||||
leaveRunningEpoch();
|
||||
pausedCooldownRemaining = 0;
|
||||
coolingDownUntil = null;
|
||||
consecutiveBadLowWindows = 0;
|
||||
resetWindow();
|
||||
|
||||
maximum = tier;
|
||||
const nextActual = reason === 'manual'
|
||||
? tier
|
||||
: TIER_RANK[actual] > TIER_RANK[tier] ? tier : actual;
|
||||
if (nextActual === actual) {
|
||||
return null;
|
||||
}
|
||||
const decision: QualityDecision = {
|
||||
previous: actual,
|
||||
next: nextActual,
|
||||
reason: 'maximum-change',
|
||||
};
|
||||
actual = nextActual;
|
||||
return decision;
|
||||
};
|
||||
|
||||
const snapshot = (): AdaptiveQualitySnapshot => ({
|
||||
maximum,
|
||||
actual,
|
||||
epoch,
|
||||
coolingDownUntil,
|
||||
eligibleSamples: samples.length,
|
||||
consecutiveBadLowWindows,
|
||||
});
|
||||
|
||||
return {
|
||||
enterRunningEpoch,
|
||||
leaveRunningEpoch,
|
||||
sampleFrame,
|
||||
setMaximum,
|
||||
snapshot,
|
||||
};
|
||||
}
|
||||
|
||||
function nearestRankP95(values: readonly number[]): number {
|
||||
const sorted = [...values].sort((first, second) => first - second);
|
||||
return sorted[Math.ceil(0.95 * sorted.length) - 1] ?? Number.POSITIVE_INFINITY;
|
||||
}
|
||||
|
||||
function nextLowerTier(tier: QualityTier): QualityTier | null {
|
||||
switch (tier) {
|
||||
case 'high':
|
||||
return 'medium';
|
||||
case 'medium':
|
||||
return 'low';
|
||||
case 'low':
|
||||
return 'static';
|
||||
case 'static':
|
||||
return null;
|
||||
}
|
||||
}
|
||||
79
src/journey/motion-policy.ts
Normal file
79
src/journey/motion-policy.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
export type QualityTier = 'high' | 'medium' | 'low' | 'static';
|
||||
|
||||
export interface MotionPolicy {
|
||||
allowSceneRequest: boolean;
|
||||
allowAutomaticVideo: boolean;
|
||||
maximumTier: QualityTier;
|
||||
reason: 'full' | 'system-reduce' | 'visitor-pause' | 'saved-static' | 'data-saver';
|
||||
}
|
||||
|
||||
export interface InitialMaximumInput {
|
||||
width: number;
|
||||
coarsePointer: boolean;
|
||||
}
|
||||
|
||||
export interface MotionPolicyInput {
|
||||
systemReduce: boolean;
|
||||
savedPause: boolean;
|
||||
savedStatic: boolean;
|
||||
dataSaver?: boolean;
|
||||
width?: number;
|
||||
coarsePointer?: boolean;
|
||||
savedMaximum?: QualityTier;
|
||||
}
|
||||
|
||||
const TIER_RANK: Readonly<Record<QualityTier, number>> = {
|
||||
static: 0,
|
||||
low: 1,
|
||||
medium: 2,
|
||||
high: 3,
|
||||
};
|
||||
|
||||
export function initialMaximum({ width, coarsePointer }: InitialMaximumInput): QualityTier {
|
||||
if (!Number.isFinite(width) || width < 768 || coarsePointer) {
|
||||
return 'low';
|
||||
}
|
||||
if (width < 1280) {
|
||||
return 'medium';
|
||||
}
|
||||
return 'high';
|
||||
}
|
||||
|
||||
export function lowerTier(first: QualityTier, second: QualityTier): QualityTier {
|
||||
return TIER_RANK[first] <= TIER_RANK[second] ? first : second;
|
||||
}
|
||||
|
||||
export function resolveMotionPolicy(input: MotionPolicyInput): MotionPolicy {
|
||||
const savedMaximum = input.savedMaximum ?? 'high';
|
||||
if (input.savedStatic || savedMaximum === 'static') {
|
||||
return blockedPolicy('saved-static');
|
||||
}
|
||||
if (input.systemReduce) {
|
||||
return blockedPolicy('system-reduce');
|
||||
}
|
||||
if (input.savedPause) {
|
||||
return blockedPolicy('visitor-pause');
|
||||
}
|
||||
if (input.dataSaver === true) {
|
||||
return blockedPolicy('data-saver');
|
||||
}
|
||||
|
||||
return {
|
||||
allowSceneRequest: true,
|
||||
allowAutomaticVideo: true,
|
||||
maximumTier: lowerTier(initialMaximum({
|
||||
width: input.width ?? 1280,
|
||||
coarsePointer: input.coarsePointer ?? false,
|
||||
}), savedMaximum),
|
||||
reason: 'full',
|
||||
};
|
||||
}
|
||||
|
||||
function blockedPolicy(reason: Exclude<MotionPolicy['reason'], 'full'>): MotionPolicy {
|
||||
return {
|
||||
allowSceneRequest: false,
|
||||
allowAutomaticVideo: false,
|
||||
maximumTier: 'static',
|
||||
reason,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user