mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-02-28 06:22:45 -08:00
Update TASK-20.2 status to done
This commit is contained in:
@@ -69,6 +69,7 @@ export {
|
||||
DEFAULT_MPV_SUBTITLE_RENDER_METRICS,
|
||||
sanitizeMpvSubtitleRenderMetrics,
|
||||
} from "./mpv-render-metrics-service";
|
||||
export { createOverlayContentMeasurementStoreService } from "./overlay-content-measurement-service";
|
||||
export { handleMpvCommandFromIpcService } from "./ipc-command-service";
|
||||
export { createFieldGroupingOverlayRuntimeService } from "./field-grouping-overlay-service";
|
||||
export { createNumericShortcutRuntimeService } from "./numeric-shortcut-service";
|
||||
|
||||
@@ -28,6 +28,7 @@ export interface IpcServiceDeps {
|
||||
getRuntimeOptions: () => unknown;
|
||||
setRuntimeOption: (id: string, value: unknown) => unknown;
|
||||
cycleRuntimeOption: (id: string, direction: 1 | -1) => unknown;
|
||||
reportOverlayContentBounds: (payload: unknown) => void;
|
||||
}
|
||||
|
||||
interface WindowLike {
|
||||
@@ -75,6 +76,7 @@ export interface IpcDepsRuntimeOptions {
|
||||
getRuntimeOptions: () => unknown;
|
||||
setRuntimeOption: (id: string, value: unknown) => unknown;
|
||||
cycleRuntimeOption: (id: string, direction: 1 | -1) => unknown;
|
||||
reportOverlayContentBounds: (payload: unknown) => void;
|
||||
}
|
||||
|
||||
export function createIpcDepsRuntimeService(
|
||||
@@ -126,6 +128,7 @@ export function createIpcDepsRuntimeService(
|
||||
getRuntimeOptions: options.getRuntimeOptions,
|
||||
setRuntimeOption: options.setRuntimeOption,
|
||||
cycleRuntimeOption: options.cycleRuntimeOption,
|
||||
reportOverlayContentBounds: options.reportOverlayContentBounds,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -253,4 +256,8 @@ export function registerIpcHandlersService(deps: IpcServiceDeps): void {
|
||||
ipcMain.handle("runtime-options:cycle", (_event, id: string, direction: 1 | -1) => {
|
||||
return deps.cycleRuntimeOption(id, direction);
|
||||
});
|
||||
|
||||
ipcMain.on("overlay-content-bounds:report", (_event: IpcMainEvent, payload: unknown) => {
|
||||
deps.reportOverlayContentBounds(payload);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import {
|
||||
createOverlayContentMeasurementStoreService,
|
||||
sanitizeOverlayContentMeasurement,
|
||||
} from "./overlay-content-measurement-service";
|
||||
|
||||
test("sanitizeOverlayContentMeasurement accepts valid payload with null rect", () => {
|
||||
const measurement = sanitizeOverlayContentMeasurement(
|
||||
{
|
||||
layer: "visible",
|
||||
measuredAtMs: 100,
|
||||
viewport: { width: 1920, height: 1080 },
|
||||
contentRect: null,
|
||||
},
|
||||
500,
|
||||
);
|
||||
|
||||
assert.deepEqual(measurement, {
|
||||
layer: "visible",
|
||||
measuredAtMs: 100,
|
||||
viewport: { width: 1920, height: 1080 },
|
||||
contentRect: null,
|
||||
});
|
||||
});
|
||||
|
||||
test("sanitizeOverlayContentMeasurement rejects invalid ranges", () => {
|
||||
const measurement = sanitizeOverlayContentMeasurement(
|
||||
{
|
||||
layer: "invisible",
|
||||
measuredAtMs: 100,
|
||||
viewport: { width: 0, height: 1080 },
|
||||
contentRect: { x: 0, y: 0, width: 100, height: 20 },
|
||||
},
|
||||
500,
|
||||
);
|
||||
|
||||
assert.equal(measurement, null);
|
||||
});
|
||||
|
||||
test("overlay measurement store keeps latest payload per layer", () => {
|
||||
const store = createOverlayContentMeasurementStoreService({
|
||||
now: () => 1000,
|
||||
warn: () => {
|
||||
// noop
|
||||
},
|
||||
});
|
||||
|
||||
const visible = store.report({
|
||||
layer: "visible",
|
||||
measuredAtMs: 900,
|
||||
viewport: { width: 1280, height: 720 },
|
||||
contentRect: { x: 50, y: 60, width: 400, height: 80 },
|
||||
});
|
||||
const invisible = store.report({
|
||||
layer: "invisible",
|
||||
measuredAtMs: 910,
|
||||
viewport: { width: 1280, height: 720 },
|
||||
contentRect: { x: 20, y: 30, width: 300, height: 40 },
|
||||
});
|
||||
|
||||
assert.equal(visible?.layer, "visible");
|
||||
assert.equal(invisible?.layer, "invisible");
|
||||
assert.equal(store.getLatestByLayer("visible")?.contentRect?.width, 400);
|
||||
assert.equal(store.getLatestByLayer("invisible")?.contentRect?.height, 40);
|
||||
});
|
||||
|
||||
test("overlay measurement store rate-limits invalid payload warnings", () => {
|
||||
let now = 1_000;
|
||||
const warnings: string[] = [];
|
||||
const store = createOverlayContentMeasurementStoreService({
|
||||
now: () => now,
|
||||
warn: (message) => {
|
||||
warnings.push(message);
|
||||
},
|
||||
});
|
||||
|
||||
store.report({ layer: "visible" });
|
||||
store.report({ layer: "visible" });
|
||||
assert.equal(warnings.length, 0);
|
||||
|
||||
now = 11_000;
|
||||
store.report({ layer: "visible" });
|
||||
assert.equal(warnings.length, 1);
|
||||
assert.match(warnings[0], /Dropped 3 invalid measurement payload/);
|
||||
});
|
||||
149
src/core/services/overlay-content-measurement-service.ts
Normal file
149
src/core/services/overlay-content-measurement-service.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
import { OverlayContentMeasurement, OverlayContentRect, OverlayLayer } from "../../types";
|
||||
const MAX_VIEWPORT = 10000;
|
||||
const MAX_RECT_DIMENSION = 10000;
|
||||
const MAX_RECT_OFFSET = 50000;
|
||||
const MAX_FUTURE_TIMESTAMP_MS = 60_000;
|
||||
const INVALID_LOG_THROTTLE_MS = 10_000;
|
||||
|
||||
type OverlayMeasurementStore = Record<OverlayLayer, OverlayContentMeasurement | null>;
|
||||
|
||||
export function sanitizeOverlayContentMeasurement(
|
||||
payload: unknown,
|
||||
nowMs: number,
|
||||
): OverlayContentMeasurement | null {
|
||||
if (!payload || typeof payload !== "object") return null;
|
||||
|
||||
const candidate = payload as {
|
||||
layer?: unknown;
|
||||
measuredAtMs?: unknown;
|
||||
viewport?: { width?: unknown; height?: unknown };
|
||||
contentRect?: { x?: unknown; y?: unknown; width?: unknown; height?: unknown } | null;
|
||||
};
|
||||
|
||||
if (candidate.layer !== "visible" && candidate.layer !== "invisible") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const viewportWidth = readFiniteInRange(candidate.viewport?.width, 1, MAX_VIEWPORT);
|
||||
const viewportHeight = readFiniteInRange(candidate.viewport?.height, 1, MAX_VIEWPORT);
|
||||
|
||||
if (!Number.isFinite(viewportWidth) || !Number.isFinite(viewportHeight)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const measuredAtMs = readFiniteInRange(
|
||||
candidate.measuredAtMs,
|
||||
1,
|
||||
nowMs + MAX_FUTURE_TIMESTAMP_MS,
|
||||
);
|
||||
if (!Number.isFinite(measuredAtMs)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const contentRect = sanitizeOverlayContentRect(candidate.contentRect);
|
||||
if (candidate.contentRect !== null && !contentRect) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
layer: candidate.layer,
|
||||
measuredAtMs,
|
||||
viewport: { width: viewportWidth, height: viewportHeight },
|
||||
contentRect,
|
||||
};
|
||||
}
|
||||
|
||||
function sanitizeOverlayContentRect(
|
||||
rect: unknown,
|
||||
): OverlayContentRect | null {
|
||||
if (rect === null || rect === undefined) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!rect || typeof rect !== "object") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const candidate = rect as {
|
||||
x?: unknown;
|
||||
y?: unknown;
|
||||
width?: unknown;
|
||||
height?: unknown;
|
||||
};
|
||||
|
||||
const width = readFiniteInRange(candidate.width, 0, MAX_RECT_DIMENSION);
|
||||
const height = readFiniteInRange(candidate.height, 0, MAX_RECT_DIMENSION);
|
||||
const x = readFiniteInRange(candidate.x, -MAX_RECT_OFFSET, MAX_RECT_OFFSET);
|
||||
const y = readFiniteInRange(candidate.y, -MAX_RECT_OFFSET, MAX_RECT_OFFSET);
|
||||
|
||||
if (
|
||||
!Number.isFinite(width) ||
|
||||
!Number.isFinite(height) ||
|
||||
!Number.isFinite(x) ||
|
||||
!Number.isFinite(y)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { x, y, width, height };
|
||||
}
|
||||
|
||||
function readFiniteInRange(
|
||||
value: unknown,
|
||||
min: number,
|
||||
max: number,
|
||||
): number {
|
||||
if (typeof value !== "number" || !Number.isFinite(value)) {
|
||||
return Number.NaN;
|
||||
}
|
||||
if (value < min || value > max) {
|
||||
return Number.NaN;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function createOverlayContentMeasurementStoreService(options?: {
|
||||
now?: () => number;
|
||||
warn?: (message: string) => void;
|
||||
}) {
|
||||
const now = options?.now ?? (() => Date.now());
|
||||
const warn = options?.warn ?? ((message: string) => console.warn(message));
|
||||
const latestByLayer: OverlayMeasurementStore = {
|
||||
visible: null,
|
||||
invisible: null,
|
||||
};
|
||||
|
||||
let droppedInvalid = 0;
|
||||
let lastInvalidLogAtMs = 0;
|
||||
|
||||
function report(payload: unknown): OverlayContentMeasurement | null {
|
||||
const nowMs = now();
|
||||
const measurement = sanitizeOverlayContentMeasurement(payload, nowMs);
|
||||
if (!measurement) {
|
||||
droppedInvalid += 1;
|
||||
if (
|
||||
droppedInvalid > 0 &&
|
||||
nowMs - lastInvalidLogAtMs >= INVALID_LOG_THROTTLE_MS
|
||||
) {
|
||||
warn(
|
||||
`[overlay-content-bounds] Dropped ${droppedInvalid} invalid measurement payload(s) in the last ${INVALID_LOG_THROTTLE_MS}ms.`,
|
||||
);
|
||||
droppedInvalid = 0;
|
||||
lastInvalidLogAtMs = nowMs;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
latestByLayer[measurement.layer] = measurement;
|
||||
return measurement;
|
||||
}
|
||||
|
||||
function getLatestByLayer(layer: OverlayLayer): OverlayContentMeasurement | null {
|
||||
return latestByLayer[layer];
|
||||
}
|
||||
|
||||
return {
|
||||
getLatestByLayer,
|
||||
report,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user