feat: add motion policy and adaptive quality

This commit is contained in:
2026-09-02 04:39:28 +03:00
parent 22d456a6ca
commit cc0887fee0
6 changed files with 1226 additions and 0 deletions

View File

@@ -0,0 +1,117 @@
import {
onMounted,
onUnmounted,
readonly,
shallowRef,
watch,
type Ref,
type WatchStopHandle,
} from 'vue';
import {
createAdaptiveQualityController,
type AdaptiveQualitySnapshot,
type QualityDecision,
} from '@/journey/adaptive-quality';
import type { QualityTier } from '@/journey/motion-policy';
const PASSIVE_LISTENER: AddEventListenerOptions = { passive: true };
export interface AdaptiveQualitySources {
maximum: Readonly<Ref<QualityTier>>;
sceneState: Readonly<Ref<string>>;
pageLifecycle: Readonly<Ref<'active' | 'frozen' | 'disposed'>>;
now?: () => number;
}
export interface AdaptiveQualityBinding {
snapshot: Readonly<Ref<AdaptiveQualitySnapshot>>;
sampleFrame(start: number, end: number): QualityDecision | null;
setMaximum(tier: QualityTier, reason: 'resize' | 'manual'): QualityDecision | null;
}
export function useAdaptiveQuality(sources: AdaptiveQualitySources): AdaptiveQualityBinding {
const controller = createAdaptiveQualityController(sources.maximum.value);
const snapshot = shallowRef(controller.snapshot());
const readNow = sources.now ?? (() => performance.now());
let visible = false;
let mounted = false;
let stopWatch: WatchStopHandle = () => {};
let teardown = (): void => {};
const publish = (): void => {
snapshot.value = controller.snapshot();
};
const isEligible = (): boolean => (
mounted
&& visible
&& sources.pageLifecycle.value === 'active'
&& sources.sceneState.value === 'running'
);
const reconcileEpoch = (): void => {
if (isEligible()) {
if (controller.snapshot().epoch === null) {
controller.enterRunningEpoch(readNow());
}
} else {
controller.leaveRunningEpoch();
}
publish();
};
const sampleFrame = (start: number, end: number): QualityDecision | null => {
const decision = controller.sampleFrame(start, end);
publish();
return decision;
};
const setMaximum = (
tier: QualityTier,
reason: 'resize' | 'manual',
): QualityDecision | null => {
const decision = controller.setMaximum(tier, reason);
reconcileEpoch();
return decision;
};
onMounted(() => {
mounted = true;
visible = document.visibilityState === 'visible';
const handleVisibility = (): void => {
visible = document.visibilityState === 'visible';
reconcileEpoch();
};
document.addEventListener('visibilitychange', handleVisibility, PASSIVE_LISTENER);
stopWatch = watch(
[sources.maximum, sources.sceneState, sources.pageLifecycle],
([maximum], [previousMaximum]) => {
if (maximum !== previousMaximum) {
controller.setMaximum(maximum, 'resize');
}
reconcileEpoch();
},
{ flush: 'sync' },
);
reconcileEpoch();
teardown = (): void => {
mounted = false;
stopWatch();
stopWatch = () => {};
document.removeEventListener('visibilitychange', handleVisibility, PASSIVE_LISTENER);
controller.leaveRunningEpoch();
publish();
};
});
onUnmounted(() => teardown());
return {
snapshot: readonly(snapshot),
sampleFrame,
setMaximum,
};
}

View File

@@ -0,0 +1,195 @@
import { onMounted, onUnmounted, readonly, shallowRef, type Ref } from 'vue';
import {
resolveMotionPolicy,
type MotionPolicy,
type QualityTier,
} from '@/journey/motion-policy';
export const MOTION_PREFERENCE_KEYS = Object.freeze({
pause: 'portfolio.motion.pause',
maximumQuality: 'portfolio.motion.maximum-quality',
volume: 'portfolio.audio.volume',
});
const PASSIVE_LISTENER: AddEventListenerOptions = { passive: true };
const QUALITY_TIERS = new Set<QualityTier>(['high', 'medium', 'low', 'static']);
interface NetworkInformationLike extends EventTarget {
saveData?: boolean;
}
export interface MotionPreferences {
policy: Readonly<Ref<MotionPolicy>>;
paused: Readonly<Ref<boolean>>;
maximumQuality: Readonly<Ref<QualityTier>>;
volume: Readonly<Ref<number>>;
systemReduce: Readonly<Ref<boolean>>;
dataSaver: Readonly<Ref<boolean>>;
setPaused(value: boolean): void;
setMaximumQuality(value: QualityTier): void;
setVolume(value: number): void;
}
const failClosedPolicy = (): MotionPolicy => resolveMotionPolicy({
systemReduce: false,
savedPause: true,
savedStatic: true,
dataSaver: false,
width: 0,
coarsePointer: true,
savedMaximum: 'static',
});
export function useMotionPreferences(): MotionPreferences {
const policy = shallowRef<MotionPolicy>(failClosedPolicy());
const paused = shallowRef(true);
const maximumQuality = shallowRef<QualityTier>('static');
const volume = shallowRef(0);
const systemReduce = shallowRef(false);
const dataSaver = shallowRef(false);
let reducedQuery: MediaQueryList | undefined;
let coarseQuery: MediaQueryList | undefined;
let connection: NetworkInformationLike | undefined;
let teardown = (): void => {};
const viewportWidth = (): number => window.visualViewport?.width ?? window.innerWidth;
const updatePolicy = (): void => {
const coarsePointer = coarseQuery?.matches ?? true;
policy.value = resolveMotionPolicy({
systemReduce: systemReduce.value,
savedPause: paused.value,
savedStatic: maximumQuality.value === 'static',
dataSaver: dataSaver.value,
width: viewportWidth(),
coarsePointer,
savedMaximum: maximumQuality.value,
});
};
const persist = (key: string, value: string): void => {
try {
if (typeof window !== 'undefined') {
window.localStorage.setItem(key, value);
}
} catch {
// Preference persistence is optional; the in-memory safe state still applies.
}
};
const setPaused = (value: boolean): void => {
paused.value = value;
persist(MOTION_PREFERENCE_KEYS.pause, String(value));
if (typeof window !== 'undefined') {
updatePolicy();
}
};
const setMaximumQuality = (value: QualityTier): void => {
maximumQuality.value = value;
persist(MOTION_PREFERENCE_KEYS.maximumQuality, value);
if (typeof window !== 'undefined') {
updatePolicy();
}
};
const setVolume = (value: number): void => {
volume.value = Number.isFinite(value) && value >= 0 && value <= 1 ? value : 0;
persist(MOTION_PREFERENCE_KEYS.volume, String(volume.value));
};
onMounted(() => {
const disposers: Array<() => void> = [];
try {
const stored = readStoredPreferences(window.localStorage);
paused.value = stored.paused;
maximumQuality.value = stored.maximumQuality;
volume.value = stored.volume;
reducedQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
coarseQuery = window.matchMedia('(pointer: coarse)');
connection = (window.navigator as Navigator & { connection?: NetworkInformationLike }).connection;
const updateReduced = (): void => {
systemReduce.value = reducedQuery?.matches ?? true;
updatePolicy();
};
const updateCoarse = (): void => updatePolicy();
const updateConnection = (): void => {
dataSaver.value = connection?.saveData === true;
updatePolicy();
};
const updateViewport = (): void => updatePolicy();
systemReduce.value = reducedQuery.matches;
dataSaver.value = connection?.saveData === true;
updatePolicy();
reducedQuery.addEventListener('change', updateReduced);
coarseQuery.addEventListener('change', updateCoarse);
connection?.addEventListener('change', updateConnection);
window.addEventListener('resize', updateViewport, PASSIVE_LISTENER);
disposers.push(
() => reducedQuery?.removeEventListener('change', updateReduced),
() => coarseQuery?.removeEventListener('change', updateCoarse),
() => connection?.removeEventListener('change', updateConnection),
() => window.removeEventListener('resize', updateViewport, PASSIVE_LISTENER),
);
} catch {
paused.value = true;
maximumQuality.value = 'static';
volume.value = 0;
systemReduce.value = true;
dataSaver.value = true;
policy.value = failClosedPolicy();
}
teardown = (): void => {
for (const dispose of disposers) {
dispose();
}
disposers.length = 0;
};
});
onUnmounted(() => teardown());
return {
policy: readonly(policy),
paused: readonly(paused),
maximumQuality: readonly(maximumQuality),
volume: readonly(volume),
systemReduce: readonly(systemReduce),
dataSaver: readonly(dataSaver),
setPaused,
setMaximumQuality,
setVolume,
};
}
function readStoredPreferences(storage: Storage): {
paused: boolean;
maximumQuality: QualityTier;
volume: number;
} {
const storedPause = storage.getItem(MOTION_PREFERENCE_KEYS.pause);
const storedMaximum = storage.getItem(MOTION_PREFERENCE_KEYS.maximumQuality);
const storedVolume = storage.getItem(MOTION_PREFERENCE_KEYS.volume);
return {
paused: storedPause === null ? false : storedPause === 'false' ? false : true,
maximumQuality: storedMaximum === null
? 'high'
: QUALITY_TIERS.has(storedMaximum as QualityTier) ? storedMaximum as QualityTier : 'static',
volume: parseVolume(storedVolume),
};
}
function parseVolume(value: string | null): number {
if (value === null) {
return 0.4;
}
const parsed = Number(value);
return Number.isFinite(parsed) && parsed >= 0 && parsed <= 1 ? parsed : 0;
}

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

View 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,
};
}