fix(renderer): calibrate macOS invisible overlay spacing

This commit is contained in:
2026-02-25 20:57:04 -08:00
parent efaf9a78cd
commit ad97948062
6 changed files with 102 additions and 13 deletions

View File

@@ -163,7 +163,7 @@ test('applyTypography applies full mpv letter spacing scale on macOS', () => {
assert.equal(ctx.dom.subtitleRoot.style.getPropertyValue('letter-spacing'), '3px'); assert.equal(ctx.dom.subtitleRoot.style.getPropertyValue('letter-spacing'), '3px');
}); });
test('applyTypography uses tighter macOS line-height for invisible multiline alignment', () => { test('applyTypography uses macOS multiline-tuned line-height for invisible overlay', () => {
const ctx = createContext({ const ctx = createContext({
isMacOSPlatform: true, isMacOSPlatform: true,
lineCount: 3, lineCount: 3,
@@ -178,7 +178,7 @@ test('applyTypography uses tighter macOS line-height for invisible multiline ali
}); });
}); });
assert.equal(ctx.dom.subtitleRoot.style.getPropertyValue('line-height'), '0.96'); assert.equal(ctx.dom.subtitleRoot.style.getPropertyValue('line-height'), '1.62');
}); });
test('applyVerticalPosition uses subtitle position margin and baseline compensation', () => { test('applyVerticalPosition uses subtitle position margin and baseline compensation', () => {

View File

@@ -1,6 +1,11 @@
import type { MpvSubtitleRenderMetrics } from '../../types'; import type { MpvSubtitleRenderMetrics } from '../../types';
import type { RendererContext } from '../context'; import type { RendererContext } from '../context';
const INVISIBLE_MACOS_VERTICAL_NUDGE_PX = 5;
const INVISIBLE_MACOS_LINE_HEIGHT_SINGLE = '1.08';
const INVISIBLE_MACOS_LINE_HEIGHT_MULTI = '1.35';
const INVISIBLE_MACOS_LINE_HEIGHT_MULTI_DENSE = '1.48';
let fontMetricsCanvas: HTMLCanvasElement | null = null; let fontMetricsCanvas: HTMLCanvasElement | null = null;
export function applyContainerBaseLayout( export function applyContainerBaseLayout(
@@ -112,6 +117,13 @@ function resolveFontFamily(rawFont: string): string {
: `"${rawFont}", sans-serif`; : `"${rawFont}", sans-serif`;
} }
export function resolveInvisibleLineHeight(lineCount: number, isMacOSPlatform: boolean): string {
if (!isMacOSPlatform) return 'normal';
if (lineCount >= 3) return INVISIBLE_MACOS_LINE_HEIGHT_MULTI_DENSE;
if (lineCount >= 2) return INVISIBLE_MACOS_LINE_HEIGHT_MULTI;
return INVISIBLE_MACOS_LINE_HEIGHT_SINGLE;
}
function resolveLetterSpacing( function resolveLetterSpacing(
spacing: number, spacing: number,
pxPerScaledPixel: number, pxPerScaledPixel: number,
@@ -123,10 +135,6 @@ function resolveLetterSpacing(
return '0px'; return '0px';
} }
function resolveInvisibleLineHeight(isMacOSPlatform: boolean): string {
return isMacOSPlatform ? '0.96' : '1';
}
function measureFontDescentPx(ctx: RendererContext): number | null { function measureFontDescentPx(ctx: RendererContext): number | null {
if (typeof document === 'undefined') return null; if (typeof document === 'undefined') return null;
const computedStyle = getComputedStyle(ctx.dom.subtitleRoot); const computedStyle = getComputedStyle(ctx.dom.subtitleRoot);
@@ -148,6 +156,42 @@ function measureFontDescentPx(ctx: RendererContext): number | null {
return metrics.actualBoundingBoxDescent; return metrics.actualBoundingBoxDescent;
} }
function applyComputedLineHeightCompensation(
ctx: RendererContext,
effectiveFontSize: number,
): void {
const computedLineHeight = parseFloat(getComputedStyle(ctx.dom.subtitleRoot).lineHeight);
if (!Number.isFinite(computedLineHeight) || computedLineHeight <= effectiveFontSize) {
return;
}
const halfLeading = (computedLineHeight - effectiveFontSize) / 2;
if (halfLeading <= 0.5) return;
const currentBottom = parseFloat(ctx.dom.subtitleContainer.style.bottom);
if (Number.isFinite(currentBottom)) {
ctx.dom.subtitleContainer.style.bottom = `${Math.max(0, currentBottom - halfLeading)}px`;
}
const currentTop = parseFloat(ctx.dom.subtitleContainer.style.top);
if (Number.isFinite(currentTop)) {
ctx.dom.subtitleContainer.style.top = `${Math.max(0, currentTop - halfLeading)}px`;
}
}
function applyMacOSAdjustments(ctx: RendererContext): void {
const isMacOSPlatform = ctx.platform.isMacOSPlatform;
if (!isMacOSPlatform) return;
const currentBottom = parseFloat(ctx.dom.subtitleContainer.style.bottom);
if (!Number.isFinite(currentBottom)) return;
ctx.dom.subtitleContainer.style.bottom = `${Math.max(
0,
currentBottom + INVISIBLE_MACOS_VERTICAL_NUDGE_PX,
)}px`;
}
export function applyTypography( export function applyTypography(
ctx: RendererContext, ctx: RendererContext,
params: { params: {
@@ -157,11 +201,14 @@ export function applyTypography(
}, },
): void { ): void {
const isMacOSPlatform = ctx.platform.isMacOSPlatform; const isMacOSPlatform = ctx.platform.isMacOSPlatform;
const lineCount = Math.max(1, ctx.state.currentInvisibleSubtitleLineCount);
const invisibleLineHeight = resolveInvisibleLineHeight(lineCount, isMacOSPlatform);
ctx.dom.subtitleRoot.style.setProperty('--invisible-sub-line-height', invisibleLineHeight);
ctx.dom.subtitleRoot.style.setProperty( ctx.dom.subtitleRoot.style.setProperty(
'line-height', 'line-height',
resolveInvisibleLineHeight(isMacOSPlatform), invisibleLineHeight,
'important', isMacOSPlatform ? 'important' : '',
); );
ctx.dom.subtitleRoot.style.fontFamily = resolveFontFamily(params.metrics.subFont); ctx.dom.subtitleRoot.style.fontFamily = resolveFontFamily(params.metrics.subFont);
ctx.dom.subtitleRoot.style.setProperty( ctx.dom.subtitleRoot.style.setProperty(
@@ -175,4 +222,7 @@ export function applyTypography(
ctx.dom.subtitleRoot.style.transform = ''; ctx.dom.subtitleRoot.style.transform = '';
ctx.dom.subtitleRoot.style.transformOrigin = ''; ctx.dom.subtitleRoot.style.transformOrigin = '';
ctx.state.invisibleMeasuredDescentPx = measureFontDescentPx(ctx); ctx.state.invisibleMeasuredDescentPx = measureFontDescentPx(ctx);
applyComputedLineHeightCompensation(ctx, params.effectiveFontSize);
applyMacOSAdjustments(ctx);
} }

View File

@@ -76,7 +76,9 @@ export function createMpvSubtitleLayoutController(
options.applyInvisibleSubtitleOffsetPosition(); options.applyInvisibleSubtitleOffsetPosition();
options.updateInvisiblePositionEditHud(); options.updateInvisiblePositionEditHud();
console.log('[invisible-overlay] Applied mpv subtitle render metrics from', source); if (source !== 'subtitle-change') {
console.log('[invisible-overlay] Applied mpv subtitle render metrics from', source);
}
} }
return { return {

View File

@@ -240,6 +240,9 @@ function runGuardedAsync(action: string, fn: () => Promise<void> | void): void {
async function init(): Promise<void> { async function init(): Promise<void> {
document.body.classList.add(`layer-${ctx.platform.overlayLayer}`); document.body.classList.add(`layer-${ctx.platform.overlayLayer}`);
if (ctx.platform.isMacOSPlatform) {
document.body.classList.add('platform-macos');
}
window.electronAPI.onSubtitle((data: SubtitleData) => { window.electronAPI.onSubtitle((data: SubtitleData) => {
runGuarded('subtitle:update', () => { runGuarded('subtitle:update', () => {
@@ -252,7 +255,7 @@ async function init(): Promise<void> {
if (ctx.platform.isInvisibleLayer && ctx.state.mpvSubtitleRenderMetrics) { if (ctx.platform.isInvisibleLayer && ctx.state.mpvSubtitleRenderMetrics) {
positioning.applyInvisibleSubtitleLayoutFromMpvMetrics( positioning.applyInvisibleSubtitleLayoutFromMpvMetrics(
ctx.state.mpvSubtitleRenderMetrics, ctx.state.mpvSubtitleRenderMetrics,
'subtitle', 'subtitle-change',
); );
} }
measurementReporter.schedule(); measurementReporter.schedule();

View File

@@ -279,7 +279,7 @@ body {
#subtitleRoot { #subtitleRoot {
text-align: center; text-align: center;
font-size: 35px; font-size: 35px;
line-height: 1.5; line-height: var(--visible-sub-line-height, 1.32);
color: #cad3f5; color: #cad3f5;
--subtitle-known-word-color: #a6da95; --subtitle-known-word-color: #a6da95;
--subtitle-n-plus-one-color: #c6a0f6; --subtitle-n-plus-one-color: #c6a0f6;
@@ -426,7 +426,12 @@ body.settings-modal-open #subtitleContainer {
#subtitleRoot br { #subtitleRoot br {
display: block; display: block;
content: ''; content: '';
margin-bottom: 0.3em; margin-bottom: var(--visible-sub-line-gap, 0.08em);
}
body.platform-macos.layer-visible #subtitleRoot {
--visible-sub-line-height: 1.64;
--visible-sub-line-gap: 0.54em;
} }
#subtitleRoot.has-selection .word:hover, #subtitleRoot.has-selection .word:hover,
@@ -452,7 +457,7 @@ body.layer-invisible #subtitleRoot .c {
-webkit-text-fill-color: transparent !important; -webkit-text-fill-color: transparent !important;
background: transparent !important; background: transparent !important;
caret-color: transparent !important; caret-color: transparent !important;
line-height: normal !important; line-height: var(--invisible-sub-line-height, normal) !important;
font-kerning: auto; font-kerning: auto;
letter-spacing: normal; letter-spacing: normal;
font-variant-ligatures: normal; font-variant-ligatures: normal;

View File

@@ -12,6 +12,7 @@ import {
normalizeSubtitle, normalizeSubtitle,
shouldRenderTokenizedSubtitle, shouldRenderTokenizedSubtitle,
} from './subtitle-render.js'; } from './subtitle-render.js';
import { resolveInvisibleLineHeight } from './positioning/invisible-layout-helpers.js';
function createToken(overrides: Partial<MergedToken>): MergedToken { function createToken(overrides: Partial<MergedToken>): MergedToken {
return { return {
@@ -328,4 +329,32 @@ test('JLPT CSS rules use underline-only styling in renderer stylesheet', () => {
); );
assert.match(block, /color:\s*var\(/); assert.match(block, /color:\s*var\(/);
} }
const invisibleBlock = extractClassBlock(
cssText,
'body.layer-invisible #subtitleRoot',
);
assert.match(
invisibleBlock,
/line-height:\s*var\(--invisible-sub-line-height,\s*normal\)\s*!important;/,
);
const visibleMacBlock = extractClassBlock(
cssText,
'body.platform-macos.layer-visible #subtitleRoot',
);
assert.match(visibleMacBlock, /--visible-sub-line-height:\s*1\.64;/);
assert.match(visibleMacBlock, /--visible-sub-line-gap:\s*0\.54em;/);
});
test('invisible overlay uses looser line height on macOS for multi-line subtitles', () => {
assert.equal(resolveInvisibleLineHeight(1, true), '1.08');
assert.equal(resolveInvisibleLineHeight(2, true), '1.5');
assert.equal(resolveInvisibleLineHeight(3, true), '1.62');
});
test('invisible overlay keeps default line height on non-macOS platforms', () => {
assert.equal(resolveInvisibleLineHeight(1, false), 'normal');
assert.equal(resolveInvisibleLineHeight(2, false), 'normal');
assert.equal(resolveInvisibleLineHeight(4, false), 'normal');
}); });