mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-03-01 18:22:41 -08:00
fix(renderer): calibrate invisible overlay metrics and hover mapping
This commit is contained in:
@@ -100,6 +100,70 @@ export function createMouseHandlers(
|
||||
return null;
|
||||
}
|
||||
|
||||
function getTextOffsetWithinSubtitleRoot(targetNode: Text, targetOffset: number): number | null {
|
||||
const clampedTargetOffset = Math.max(0, Math.min(targetOffset, targetNode.data.length));
|
||||
const walker = document.createTreeWalker(ctx.dom.subtitleRoot, NodeFilter.SHOW_ALL);
|
||||
let totalOffset = 0;
|
||||
|
||||
let node: Node | null = walker.currentNode;
|
||||
while (node) {
|
||||
if (node.nodeType === Node.TEXT_NODE) {
|
||||
const textNode = node as Text;
|
||||
if (textNode === targetNode) {
|
||||
return totalOffset + clampedTargetOffset;
|
||||
}
|
||||
totalOffset += textNode.data.length;
|
||||
} else if (
|
||||
node.nodeType === Node.ELEMENT_NODE &&
|
||||
(node as Element).tagName.toUpperCase() === 'BR'
|
||||
) {
|
||||
totalOffset += 1;
|
||||
}
|
||||
node = walker.nextNode();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function resolveHoveredInvisibleTokenIndex(event: MouseEvent): number | null {
|
||||
if (!(event.target instanceof Node)) {
|
||||
return null;
|
||||
}
|
||||
if (!ctx.dom.subtitleRoot.contains(event.target)) {
|
||||
return null;
|
||||
}
|
||||
if (ctx.state.invisibleTokenHoverRanges.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const caretRange = getCaretTextPointRange(event.clientX, event.clientY);
|
||||
if (!caretRange) {
|
||||
return null;
|
||||
}
|
||||
if (caretRange.startContainer.nodeType !== Node.TEXT_NODE) {
|
||||
return null;
|
||||
}
|
||||
if (!ctx.dom.subtitleRoot.contains(caretRange.startContainer)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const textOffset = getTextOffsetWithinSubtitleRoot(
|
||||
caretRange.startContainer as Text,
|
||||
caretRange.startOffset,
|
||||
);
|
||||
if (textOffset === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
for (const range of ctx.state.invisibleTokenHoverRanges) {
|
||||
if (textOffset >= range.start && textOffset < range.end) {
|
||||
return range.tokenIndex;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function getWordBoundsAtOffset(
|
||||
text: string,
|
||||
offset: number,
|
||||
@@ -218,18 +282,8 @@ export function createMouseHandlers(
|
||||
};
|
||||
|
||||
ctx.dom.subtitleRoot.addEventListener('mousemove', (event: MouseEvent) => {
|
||||
if (!(event.target instanceof Element)) {
|
||||
queueNullHoveredToken();
|
||||
return;
|
||||
}
|
||||
const target = event.target.closest<HTMLElement>('.word[data-token-index]');
|
||||
if (!target || !ctx.dom.subtitleRoot.contains(target)) {
|
||||
queueNullHoveredToken();
|
||||
return;
|
||||
}
|
||||
const rawTokenIndex = target.dataset.tokenIndex;
|
||||
const tokenIndex = rawTokenIndex ? Number.parseInt(rawTokenIndex, 10) : Number.NaN;
|
||||
if (!Number.isInteger(tokenIndex) || tokenIndex < 0) {
|
||||
const tokenIndex = resolveHoveredInvisibleTokenIndex(event);
|
||||
if (tokenIndex === null) {
|
||||
queueNullHoveredToken();
|
||||
return;
|
||||
}
|
||||
|
||||
112
src/renderer/positioning/invisible-layout-metrics.test.ts
Normal file
112
src/renderer/positioning/invisible-layout-metrics.test.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import { strict as assert } from 'node:assert';
|
||||
import { afterEach, test } from 'node:test';
|
||||
import type { MpvSubtitleRenderMetrics } from '../../types';
|
||||
import {
|
||||
applyPlatformFontCompensation,
|
||||
calculateOsdScale,
|
||||
calculateSubtitleMetrics,
|
||||
} from './invisible-layout-metrics';
|
||||
|
||||
const BASE_METRICS: MpvSubtitleRenderMetrics = {
|
||||
subPos: 100,
|
||||
subFontSize: 40,
|
||||
subScale: 1,
|
||||
subMarginY: 34,
|
||||
subMarginX: 19,
|
||||
subFont: 'sans-serif',
|
||||
subSpacing: 0,
|
||||
subBold: false,
|
||||
subItalic: false,
|
||||
subBorderSize: 2,
|
||||
subShadowOffset: 0,
|
||||
subAssOverride: 'yes',
|
||||
subScaleByWindow: false,
|
||||
subUseMargins: true,
|
||||
osdHeight: 720,
|
||||
osdDimensions: {
|
||||
w: 1920,
|
||||
h: 1080,
|
||||
ml: 100,
|
||||
mr: 100,
|
||||
mt: 80,
|
||||
mb: 60,
|
||||
},
|
||||
};
|
||||
|
||||
const originalWindow = globalThis.window;
|
||||
|
||||
function setWindowDimensions(width: number, height: number, devicePixelRatio: number): void {
|
||||
Object.defineProperty(globalThis, 'window', {
|
||||
configurable: true,
|
||||
value: {
|
||||
innerWidth: width,
|
||||
innerHeight: height,
|
||||
devicePixelRatio,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
Object.defineProperty(globalThis, 'window', {
|
||||
configurable: true,
|
||||
value: originalWindow,
|
||||
});
|
||||
});
|
||||
|
||||
test('calculateSubtitleMetrics uses video insets for scale-by-video even when subUseMargins is true', () => {
|
||||
setWindowDimensions(1920, 1080, 1);
|
||||
|
||||
const ctx = {
|
||||
platform: {
|
||||
isMacOSPlatform: false,
|
||||
isLinuxPlatform: false,
|
||||
},
|
||||
} as const;
|
||||
|
||||
const result = calculateSubtitleMetrics(ctx as never, BASE_METRICS);
|
||||
|
||||
const expectedPxPerScaledPixel = (1080 - 80 - 60) / 720;
|
||||
assert.equal(result.pxPerScaledPixel, expectedPxPerScaledPixel);
|
||||
assert.equal(result.effectiveFontSize, BASE_METRICS.subFontSize * expectedPxPerScaledPixel);
|
||||
});
|
||||
|
||||
test('calculateSubtitleMetrics keeps osd insets for positioning even when subUseMargins is true', () => {
|
||||
setWindowDimensions(1920, 1080, 1);
|
||||
|
||||
const ctx = {
|
||||
platform: {
|
||||
isMacOSPlatform: false,
|
||||
isLinuxPlatform: false,
|
||||
},
|
||||
} as const;
|
||||
|
||||
const result = calculateSubtitleMetrics(ctx as never, BASE_METRICS);
|
||||
|
||||
assert.equal(result.leftInset, 100);
|
||||
assert.equal(result.rightInset, 100);
|
||||
assert.equal(result.topInset, 80);
|
||||
assert.equal(result.bottomInset, 60);
|
||||
assert.equal(result.horizontalAvailable, 1720);
|
||||
});
|
||||
|
||||
test('applyPlatformFontCompensation applies calibrated macOS factor', () => {
|
||||
assert.equal(applyPlatformFontCompensation(100, true), 82);
|
||||
assert.equal(applyPlatformFontCompensation(100, false), 100);
|
||||
});
|
||||
|
||||
test('calculateOsdScale snaps near-DPR macOS ratios to devicePixelRatio', () => {
|
||||
const metrics = {
|
||||
...BASE_METRICS,
|
||||
osdDimensions: {
|
||||
w: 3024,
|
||||
h: 1701,
|
||||
ml: 116,
|
||||
mr: 116,
|
||||
mt: 28,
|
||||
mb: 28,
|
||||
},
|
||||
};
|
||||
|
||||
const scale = calculateOsdScale(metrics, true, 1728, 972, 2);
|
||||
assert.equal(scale, 2);
|
||||
});
|
||||
@@ -39,6 +39,21 @@ export function calculateOsdScale(
|
||||
? ratios.reduce((sum, value) => sum + value, 0) / ratios.length
|
||||
: devicePixelRatio;
|
||||
|
||||
const candidates = [1, devicePixelRatio].filter((candidate, index, list) => {
|
||||
if (!Number.isFinite(candidate) || candidate <= 0) return false;
|
||||
return list.indexOf(candidate) === index;
|
||||
});
|
||||
|
||||
const snappedScale = candidates.reduce((best, candidate) => {
|
||||
const bestDistance = Math.abs(avgRatio - best);
|
||||
const candidateDistance = Math.abs(avgRatio - candidate);
|
||||
return candidateDistance < bestDistance ? candidate : best;
|
||||
}, candidates[0] ?? 1);
|
||||
|
||||
if (Math.abs(avgRatio - snappedScale) <= 0.35) {
|
||||
return snappedScale;
|
||||
}
|
||||
|
||||
return avgRatio > 1.25 ? avgRatio : 1;
|
||||
}
|
||||
|
||||
@@ -67,7 +82,7 @@ export function applyPlatformFontCompensation(
|
||||
fontSizePx: number,
|
||||
isMacOSPlatform: boolean,
|
||||
): number {
|
||||
return isMacOSPlatform ? fontSizePx * 0.87 : fontSizePx;
|
||||
return isMacOSPlatform ? fontSizePx * 0.82 : fontSizePx;
|
||||
}
|
||||
|
||||
function calculateGeometry(
|
||||
@@ -82,11 +97,11 @@ function calculateGeometry(
|
||||
const videoTopInset = dims ? dims.mt / osdToCssScale : 0;
|
||||
const videoBottomInset = dims ? dims.mb / osdToCssScale : 0;
|
||||
|
||||
const anchorToVideoArea = !metrics.subUseMargins;
|
||||
const leftInset = anchorToVideoArea ? videoLeftInset : 0;
|
||||
const rightInset = anchorToVideoArea ? videoRightInset : 0;
|
||||
const topInset = anchorToVideoArea ? videoTopInset : 0;
|
||||
const bottomInset = anchorToVideoArea ? videoBottomInset : 0;
|
||||
// Keep layout anchored to the same drawable video region represented by osd-dimensions.
|
||||
const leftInset = videoLeftInset;
|
||||
const rightInset = videoRightInset;
|
||||
const topInset = videoTopInset;
|
||||
const bottomInset = videoBottomInset;
|
||||
const horizontalAvailable = Math.max(0, renderAreaWidth - leftInset - rightInset);
|
||||
|
||||
return {
|
||||
@@ -112,7 +127,9 @@ export function calculateSubtitleMetrics(
|
||||
window.devicePixelRatio || 1,
|
||||
);
|
||||
const geometry = calculateGeometry(metrics, osdToCssScale);
|
||||
const videoHeight = geometry.renderAreaHeight - geometry.topInset - geometry.bottomInset;
|
||||
const rawVideoTopInset = metrics.osdDimensions ? metrics.osdDimensions.mt / osdToCssScale : 0;
|
||||
const rawVideoBottomInset = metrics.osdDimensions ? metrics.osdDimensions.mb / osdToCssScale : 0;
|
||||
const videoHeight = geometry.renderAreaHeight - rawVideoTopInset - rawVideoBottomInset;
|
||||
const scaleRefHeight = metrics.subScaleByWindow ? geometry.renderAreaHeight : videoHeight;
|
||||
const pxPerScaledPixel = Math.max(0.1, scaleRefHeight / 720);
|
||||
const computedFontSize =
|
||||
|
||||
@@ -47,24 +47,24 @@ export function createMpvSubtitleLayoutController(
|
||||
hAlign: alignment.hAlign,
|
||||
});
|
||||
|
||||
applyTypography(ctx, {
|
||||
metrics,
|
||||
pxPerScaledPixel: geometry.pxPerScaledPixel,
|
||||
effectiveFontSize: geometry.effectiveFontSize,
|
||||
});
|
||||
|
||||
applyVerticalPosition(ctx, {
|
||||
metrics,
|
||||
renderAreaHeight: geometry.renderAreaHeight,
|
||||
topInset: geometry.topInset,
|
||||
bottomInset: geometry.bottomInset,
|
||||
marginY: geometry.marginY,
|
||||
effectiveFontSize: geometry.effectiveFontSize,
|
||||
borderPx: effectiveBorderSize,
|
||||
shadowPx: effectiveShadowOffset,
|
||||
measuredDescentPx: ctx.state.invisibleMeasuredDescentPx,
|
||||
vAlign: alignment.vAlign,
|
||||
});
|
||||
|
||||
applyTypography(ctx, {
|
||||
metrics,
|
||||
pxPerScaledPixel: geometry.pxPerScaledPixel,
|
||||
effectiveFontSize: geometry.effectiveFontSize,
|
||||
});
|
||||
|
||||
ctx.state.invisibleLayoutBaseLeftPx = parseFloat(ctx.dom.subtitleContainer.style.left) || 0;
|
||||
|
||||
const parsedBottom = parseFloat(ctx.dom.subtitleContainer.style.bottom);
|
||||
|
||||
@@ -249,6 +249,12 @@ async function init(): Promise<void> {
|
||||
lastSubtitlePreview = truncateForErrorLog(data.text);
|
||||
}
|
||||
subtitleRenderer.renderSubtitle(data);
|
||||
if (ctx.platform.isInvisibleLayer && ctx.state.mpvSubtitleRenderMetrics) {
|
||||
positioning.applyInvisibleSubtitleLayoutFromMpvMetrics(
|
||||
ctx.state.mpvSubtitleRenderMetrics,
|
||||
'subtitle',
|
||||
);
|
||||
}
|
||||
measurementReporter.schedule();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -72,6 +72,13 @@ export type RendererState = {
|
||||
lastHoverSelectionKey: string;
|
||||
lastHoverSelectionNode: Text | null;
|
||||
lastHoveredTokenIndex: number | null;
|
||||
invisibleTokenHoverSourceText: string;
|
||||
invisibleTokenHoverRanges: Array<{
|
||||
start: number;
|
||||
end: number;
|
||||
tokenIndex: number;
|
||||
}>;
|
||||
invisibleMeasuredDescentPx: number | null;
|
||||
|
||||
knownWordColor: string;
|
||||
nPlusOneColor: string;
|
||||
@@ -150,6 +157,9 @@ export function createRendererState(): RendererState {
|
||||
lastHoverSelectionKey: '',
|
||||
lastHoverSelectionNode: null,
|
||||
lastHoveredTokenIndex: null,
|
||||
invisibleTokenHoverSourceText: '',
|
||||
invisibleTokenHoverRanges: [],
|
||||
invisibleMeasuredDescentPx: null,
|
||||
|
||||
knownWordColor: '#a6da95',
|
||||
nPlusOneColor: '#c6a0f6',
|
||||
|
||||
@@ -483,7 +483,7 @@ body.layer-invisible.debug-invisible-visualization #subtitleRoot .word,
|
||||
body.layer-invisible.debug-invisible-visualization #subtitleRoot .c {
|
||||
color: #ed8796 !important;
|
||||
-webkit-text-fill-color: #ed8796 !important;
|
||||
-webkit-text-stroke: calc(var(--sub-border-size, 2px) * 2) rgba(0, 0, 0, 0.85) !important;
|
||||
-webkit-text-stroke: var(--sub-border-size, 2px) rgba(0, 0, 0, 0.85) !important;
|
||||
paint-order: stroke fill !important;
|
||||
text-shadow: none !important;
|
||||
}
|
||||
@@ -516,7 +516,7 @@ body.layer-invisible.invisible-position-edit #subtitleRoot .word,
|
||||
body.layer-invisible.invisible-position-edit #subtitleRoot .c {
|
||||
color: #ed8796 !important;
|
||||
-webkit-text-fill-color: #ed8796 !important;
|
||||
-webkit-text-stroke: calc(var(--sub-border-size, 2px) * 2) rgba(0, 0, 0, 0.85) !important;
|
||||
-webkit-text-stroke: var(--sub-border-size, 2px) rgba(0, 0, 0, 0.85) !important;
|
||||
paint-order: stroke fill !important;
|
||||
text-shadow: none !important;
|
||||
}
|
||||
|
||||
@@ -5,7 +5,13 @@ import path from 'node:path';
|
||||
|
||||
import type { MergedToken } from '../types';
|
||||
import { PartOfSpeech } from '../types.js';
|
||||
import { alignTokensToSourceText, computeWordClass, normalizeSubtitle } from './subtitle-render.js';
|
||||
import {
|
||||
alignTokensToSourceText,
|
||||
buildInvisibleTokenHoverRanges,
|
||||
computeWordClass,
|
||||
normalizeSubtitle,
|
||||
shouldRenderTokenizedSubtitle,
|
||||
} from './subtitle-render.js';
|
||||
|
||||
function createToken(overrides: Partial<MergedToken>): MergedToken {
|
||||
return {
|
||||
@@ -248,6 +254,29 @@ test('alignTokensToSourceText avoids duplicate tail when later token surface doe
|
||||
);
|
||||
});
|
||||
|
||||
test('buildInvisibleTokenHoverRanges tracks token offsets across text separators', () => {
|
||||
const tokens = [
|
||||
createToken({ surface: 'キリキリと' }),
|
||||
createToken({ surface: 'かかってこい' }),
|
||||
];
|
||||
|
||||
const ranges = buildInvisibleTokenHoverRanges(tokens, 'キリキリと\nかかってこい');
|
||||
assert.deepEqual(ranges, [
|
||||
{ start: 0, end: 5, tokenIndex: 0 },
|
||||
{ start: 6, end: 12, tokenIndex: 1 },
|
||||
]);
|
||||
});
|
||||
|
||||
test('buildInvisibleTokenHoverRanges ignores unmatched token surfaces', () => {
|
||||
const tokens = [
|
||||
createToken({ surface: '君たちが潰した拠点に' }),
|
||||
createToken({ surface: '教団の主力は1人もいない' }),
|
||||
];
|
||||
|
||||
const ranges = buildInvisibleTokenHoverRanges(tokens, '君たちが潰した拠点に\n教団の主力は1人もいない');
|
||||
assert.deepEqual(ranges, [{ start: 0, end: 10, tokenIndex: 0 }]);
|
||||
});
|
||||
|
||||
test('normalizeSubtitle collapses explicit line breaks when collapseLineBreaks is enabled', () => {
|
||||
assert.equal(
|
||||
normalizeSubtitle('常人が使えば\\Nその圧倒的な力に\\n体が耐えきれず死に至るが…', true, true),
|
||||
@@ -255,6 +284,15 @@ test('normalizeSubtitle collapses explicit line breaks when collapseLineBreaks i
|
||||
);
|
||||
});
|
||||
|
||||
test('shouldRenderTokenizedSubtitle disables token rendering on invisible layer', () => {
|
||||
assert.equal(shouldRenderTokenizedSubtitle(true, 5), false);
|
||||
});
|
||||
|
||||
test('shouldRenderTokenizedSubtitle enables token rendering on visible layer when tokens exist', () => {
|
||||
assert.equal(shouldRenderTokenizedSubtitle(false, 5), true);
|
||||
assert.equal(shouldRenderTokenizedSubtitle(false, 0), false);
|
||||
});
|
||||
|
||||
test('JLPT CSS rules use underline-only styling in renderer stylesheet', () => {
|
||||
const distCssPath = path.join(process.cwd(), 'dist', 'renderer', 'style.css');
|
||||
const srcCssPath = path.join(process.cwd(), 'src', 'renderer', 'style.css');
|
||||
|
||||
@@ -9,6 +9,19 @@ type FrequencyRenderSettings = {
|
||||
bandedColors: [string, string, string, string, string];
|
||||
};
|
||||
|
||||
export type InvisibleTokenHoverRange = {
|
||||
start: number;
|
||||
end: number;
|
||||
tokenIndex: number;
|
||||
};
|
||||
|
||||
export function shouldRenderTokenizedSubtitle(
|
||||
isInvisibleLayer: boolean,
|
||||
tokenCount: number,
|
||||
): boolean {
|
||||
return !isInvisibleLayer && tokenCount > 0;
|
||||
}
|
||||
|
||||
function isWhitespaceOnly(value: string): boolean {
|
||||
return value.trim().length === 0;
|
||||
}
|
||||
@@ -218,6 +231,40 @@ export function alignTokensToSourceText(
|
||||
return segments;
|
||||
}
|
||||
|
||||
export function buildInvisibleTokenHoverRanges(
|
||||
tokens: MergedToken[],
|
||||
sourceText: string,
|
||||
): InvisibleTokenHoverRange[] {
|
||||
if (tokens.length === 0 || sourceText.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const segments = alignTokensToSourceText(tokens, sourceText);
|
||||
const ranges: InvisibleTokenHoverRange[] = [];
|
||||
let cursor = 0;
|
||||
|
||||
for (const segment of segments) {
|
||||
if (segment.kind === 'text') {
|
||||
cursor += segment.text.length;
|
||||
continue;
|
||||
}
|
||||
|
||||
const tokenLength = segment.token.surface.length;
|
||||
if (tokenLength <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
ranges.push({
|
||||
start: cursor,
|
||||
end: cursor + tokenLength,
|
||||
tokenIndex: segment.tokenIndex,
|
||||
});
|
||||
cursor += tokenLength;
|
||||
}
|
||||
|
||||
return ranges;
|
||||
}
|
||||
|
||||
export function computeWordClass(
|
||||
token: MergedToken,
|
||||
frequencySettings?: Partial<FrequencyRenderSettings>,
|
||||
@@ -312,27 +359,21 @@ export function createSubtitleRenderer(ctx: RendererContext) {
|
||||
if (!text) return;
|
||||
|
||||
if (ctx.platform.isInvisibleLayer) {
|
||||
// Keep natural kerning/shaping in invisible layer to match mpv glyph placement.
|
||||
const normalizedInvisible = normalizeSubtitle(text, false);
|
||||
ctx.state.currentInvisibleSubtitleLineCount = Math.max(
|
||||
1,
|
||||
normalizedInvisible.split('\n').length,
|
||||
);
|
||||
if (tokens && tokens.length > 0) {
|
||||
renderWithTokens(
|
||||
ctx.dom.subtitleRoot,
|
||||
tokens,
|
||||
getFrequencyRenderSettings(),
|
||||
text,
|
||||
true,
|
||||
);
|
||||
} else {
|
||||
renderPlainTextPreserveLineBreaks(ctx.dom.subtitleRoot, normalizedInvisible);
|
||||
}
|
||||
ctx.state.invisibleTokenHoverSourceText = normalizedInvisible;
|
||||
ctx.state.invisibleTokenHoverRanges =
|
||||
tokens && tokens.length > 0 ? buildInvisibleTokenHoverRanges(tokens, normalizedInvisible) : [];
|
||||
renderPlainTextPreserveLineBreaks(ctx.dom.subtitleRoot, normalizedInvisible);
|
||||
return;
|
||||
}
|
||||
|
||||
const normalized = normalizeSubtitle(text, true, !ctx.state.preserveSubtitleLineBreaks);
|
||||
if (tokens && tokens.length > 0) {
|
||||
if (shouldRenderTokenizedSubtitle(ctx.platform.isInvisibleLayer, tokens?.length ?? 0) && tokens) {
|
||||
renderWithTokens(
|
||||
ctx.dom.subtitleRoot,
|
||||
tokens,
|
||||
|
||||
Reference in New Issue
Block a user