fix(renderer): calibrate invisible overlay metrics and hover mapping

This commit is contained in:
2026-02-25 00:44:25 -08:00
parent 058d359553
commit efaf9a78cd
14 changed files with 410 additions and 44 deletions

View File

@@ -1,4 +1,4 @@
.PHONY: help deps build build-launcher install build-linux build-macos build-macos-unsigned clean install-linux install-macos install-plugin uninstall uninstall-linux uninstall-macos print-dirs pretty ensure-bun generate-config generate-example-config docs-dev docs docs-preview dev-start dev-start-macos dev-toggle dev-stop .PHONY: help deps build build-launcher install build-linux build-macos build-macos-unsigned clean install-linux install-macos install-plugin uninstall uninstall-linux uninstall-macos print-dirs pretty ensure-bun generate-config generate-example-config docs-dev docs docs-preview dev-start dev-start-macos dev-watch dev-watch-macos dev-toggle dev-stop
APP_NAME := subminer APP_NAME := subminer
THEME_SOURCE := assets/themes/subminer.rasi THEME_SOURCE := assets/themes/subminer.rasi
@@ -53,6 +53,8 @@ help:
" clean Remove build artifacts (dist/, release/, AppImage, binary)" \ " clean Remove build artifacts (dist/, release/, AppImage, binary)" \
" dev-start Build and launch local Electron app" \ " dev-start Build and launch local Electron app" \
" dev-start-macos Build and launch local Electron app with macOS tracker backend" \ " dev-start-macos Build and launch local Electron app with macOS tracker backend" \
" dev-watch Start fast watch loop (tsc + renderer + Electron dev app)" \
" dev-watch-macos Start watch loop with forced macOS tracker backend" \
" dev-toggle Toggle overlay in a running local Electron app" \ " dev-toggle Toggle overlay in a running local Electron app" \
" dev-stop Stop a running local Electron app" \ " dev-stop Stop a running local Electron app" \
" docs-dev Run VitePress docs dev server" \ " docs-dev Run VitePress docs dev server" \
@@ -173,6 +175,12 @@ dev-start-macos: ensure-bun
@bun run build @bun run build
@bun run electron . --start --backend macos @bun run electron . --start --backend macos
dev-watch: ensure-bun
@bash scripts/dev-watch.sh
dev-watch-macos: ensure-bun
@bash scripts/dev-watch.sh --start --dev --backend macos
dev-toggle: ensure-bun dev-toggle: ensure-bun
@bun run electron . --toggle @bun run electron . --toggle

View File

@@ -60,6 +60,15 @@ bun run dev # builds + launches with --start --dev
electron . --start --dev --log-level debug # equivalent Electron launch with verbose logging electron . --start --dev --log-level debug # equivalent Electron launch with verbose logging
electron . --background # tray/background mode, minimal default logging electron . --background # tray/background mode, minimal default logging
make dev-start # build + launch via Makefile make dev-start # build + launch via Makefile
make dev-watch # watch TS + renderer and launch Electron (faster edit loop)
make dev-watch-macos # same as dev-watch, forcing --backend macos
```
For mpv-plugin-driven testing without exporting `SUBMINER_BINARY_PATH` each run, set a one-time
dev binary path in `~/.config/mpv/script-opts/subminer.conf`:
```ini
binary_path=/absolute/path/to/SubMiner/scripts/subminer-dev.sh
``` ```
## Testing ## Testing

View File

@@ -21,8 +21,8 @@
"test:config:smoke:dist": "bun test dist/config/path-resolution.test.js", "test:config:smoke:dist": "bun test dist/config/path-resolution.test.js",
"test:launcher:smoke:src": "bun test launcher/smoke.e2e.test.ts", "test:launcher:smoke:src": "bun test launcher/smoke.e2e.test.ts",
"test:launcher:src": "bun test launcher/config.test.ts launcher/config-domain-parsers.test.ts launcher/parse-args.test.ts launcher/main.test.ts launcher/commands/command-modules.test.ts launcher/smoke.e2e.test.ts", "test:launcher:src": "bun test launcher/config.test.ts launcher/config-domain-parsers.test.ts launcher/parse-args.test.ts launcher/main.test.ts launcher/commands/command-modules.test.ts launcher/smoke.e2e.test.ts",
"test:core:src": "bun test src/cli/args.test.ts src/cli/help.test.ts src/core/services/cli-command.test.ts src/core/services/field-grouping-overlay.test.ts src/core/services/numeric-shortcut-session.test.ts src/core/services/secondary-subtitle.test.ts src/core/services/mpv-render-metrics.test.ts src/core/services/overlay-content-measurement.test.ts src/core/services/mpv-control.test.ts src/core/services/mpv.test.ts src/core/services/runtime-options-ipc.test.ts src/core/services/runtime-config.test.ts src/core/services/config-hot-reload.test.ts src/core/services/discord-presence.test.ts src/core/services/tokenizer.test.ts src/core/services/tokenizer/annotation-stage.test.ts src/core/services/tokenizer/parser-selection-stage.test.ts src/core/services/tokenizer/parser-enrichment-stage.test.ts src/core/services/subsync.test.ts src/core/services/overlay-bridge.test.ts src/core/services/overlay-shortcut-handler.test.ts src/core/services/mining.test.ts src/core/services/anki-jimaku.test.ts src/core/services/jellyfin.test.ts src/core/services/jellyfin-remote.test.ts src/core/services/immersion-tracker-service.test.ts src/core/services/app-ready.test.ts src/core/services/startup-bootstrap.test.ts src/core/services/subtitle-processing-controller.test.ts src/core/services/anilist/anilist-update-queue.test.ts src/core/utils/shortcut-config.test.ts src/renderer/error-recovery.test.ts src/subsync/utils.test.ts src/main/anilist-url-guard.test.ts src/window-trackers/x11-tracker.test.ts launcher/config.test.ts launcher/config-domain-parsers.test.ts launcher/parse-args.test.ts launcher/main.test.ts launcher/commands/command-modules.test.ts", "test:core:src": "bun test src/cli/args.test.ts src/cli/help.test.ts src/core/services/cli-command.test.ts src/core/services/field-grouping-overlay.test.ts src/core/services/numeric-shortcut-session.test.ts src/core/services/secondary-subtitle.test.ts src/core/services/mpv-render-metrics.test.ts src/core/services/overlay-content-measurement.test.ts src/core/services/mpv-control.test.ts src/core/services/mpv.test.ts src/core/services/runtime-options-ipc.test.ts src/core/services/runtime-config.test.ts src/core/services/config-hot-reload.test.ts src/core/services/discord-presence.test.ts src/core/services/tokenizer.test.ts src/core/services/tokenizer/annotation-stage.test.ts src/core/services/tokenizer/parser-selection-stage.test.ts src/core/services/tokenizer/parser-enrichment-stage.test.ts src/core/services/subsync.test.ts src/core/services/overlay-bridge.test.ts src/core/services/overlay-shortcut-handler.test.ts src/core/services/mining.test.ts src/core/services/anki-jimaku.test.ts src/core/services/jellyfin.test.ts src/core/services/jellyfin-remote.test.ts src/core/services/immersion-tracker-service.test.ts src/core/services/app-ready.test.ts src/core/services/startup-bootstrap.test.ts src/core/services/subtitle-processing-controller.test.ts src/core/services/anilist/anilist-update-queue.test.ts src/core/utils/shortcut-config.test.ts src/renderer/error-recovery.test.ts src/renderer/subtitle-render.test.ts src/renderer/positioning/invisible-layout-helpers.test.ts src/renderer/positioning/invisible-layout-metrics.test.ts src/subsync/utils.test.ts src/main/anilist-url-guard.test.ts src/window-trackers/x11-tracker.test.ts launcher/config.test.ts launcher/config-domain-parsers.test.ts launcher/parse-args.test.ts launcher/main.test.ts launcher/commands/command-modules.test.ts",
"test:core:dist": "bun test dist/cli/args.test.js dist/cli/help.test.js dist/core/services/cli-command.test.js dist/core/services/ipc.test.js dist/core/services/anki-jimaku-ipc.test.js dist/core/services/field-grouping-overlay.test.js dist/core/services/numeric-shortcut-session.test.js dist/core/services/secondary-subtitle.test.js dist/core/services/mpv-render-metrics.test.js dist/core/services/overlay-content-measurement.test.js dist/core/services/mpv-control.test.js dist/core/services/mpv.test.js dist/core/services/runtime-options-ipc.test.js dist/core/services/runtime-config.test.js dist/core/services/config-hot-reload.test.js dist/core/services/discord-presence.test.js dist/core/services/tokenizer.test.js dist/core/services/tokenizer/annotation-stage.test.js dist/core/services/tokenizer/parser-selection-stage.test.js dist/core/services/tokenizer/parser-enrichment-stage.test.js dist/core/services/subsync.test.js dist/core/services/overlay-bridge.test.js dist/core/services/overlay-manager.test.js dist/core/services/overlay-shortcut-handler.test.js dist/core/services/mining.test.js dist/core/services/anki-jimaku.test.js dist/core/services/jellyfin.test.js dist/core/services/jellyfin-remote.test.js dist/core/services/immersion-tracker-service.test.js dist/core/services/app-ready.test.js dist/core/services/startup-bootstrap.test.js dist/core/services/subtitle-processing-controller.test.js dist/core/services/anilist/anilist-token-store.test.js dist/core/services/anilist/anilist-update-queue.test.js dist/renderer/error-recovery.test.js dist/subsync/utils.test.js dist/main/anilist-url-guard.test.js dist/window-trackers/x11-tracker.test.js", "test:core:dist": "bun test dist/cli/args.test.js dist/cli/help.test.js dist/core/services/cli-command.test.js dist/core/services/ipc.test.js dist/core/services/anki-jimaku-ipc.test.js dist/core/services/field-grouping-overlay.test.js dist/core/services/numeric-shortcut-session.test.js dist/core/services/secondary-subtitle.test.js dist/core/services/mpv-render-metrics.test.js dist/core/services/overlay-content-measurement.test.js dist/core/services/mpv-control.test.js dist/core/services/mpv.test.js dist/core/services/runtime-options-ipc.test.js dist/core/services/runtime-config.test.js dist/core/services/config-hot-reload.test.js dist/core/services/discord-presence.test.js dist/core/services/tokenizer.test.js dist/core/services/tokenizer/annotation-stage.test.js dist/core/services/tokenizer/parser-selection-stage.test.js dist/core/services/tokenizer/parser-enrichment-stage.test.js dist/core/services/subsync.test.js dist/core/services/overlay-bridge.test.js dist/core/services/overlay-manager.test.js dist/core/services/overlay-shortcut-handler.test.js dist/core/services/mining.test.js dist/core/services/anki-jimaku.test.js dist/core/services/jellyfin.test.js dist/core/services/jellyfin-remote.test.js dist/core/services/immersion-tracker-service.test.js dist/core/services/app-ready.test.js dist/core/services/startup-bootstrap.test.js dist/core/services/subtitle-processing-controller.test.js dist/core/services/anilist/anilist-token-store.test.js dist/core/services/anilist/anilist-update-queue.test.js dist/renderer/error-recovery.test.js dist/renderer/subtitle-render.test.js dist/renderer/positioning/invisible-layout-helpers.test.js dist/renderer/positioning/invisible-layout-metrics.test.js dist/subsync/utils.test.js dist/main/anilist-url-guard.test.js dist/window-trackers/x11-tracker.test.js",
"test:core:smoke:dist": "bun test dist/cli/help.test.js dist/core/services/runtime-config.test.js dist/core/services/ipc.test.js dist/core/services/overlay-manager.test.js dist/core/services/anilist/anilist-token-store.test.js dist/core/services/startup-bootstrap.test.js dist/renderer/error-recovery.test.js dist/main/anilist-url-guard.test.js dist/window-trackers/x11-tracker.test.js", "test:core:smoke:dist": "bun test dist/cli/help.test.js dist/core/services/runtime-config.test.js dist/core/services/ipc.test.js dist/core/services/overlay-manager.test.js dist/core/services/anilist/anilist-token-store.test.js dist/core/services/startup-bootstrap.test.js dist/renderer/error-recovery.test.js dist/main/anilist-url-guard.test.js dist/window-trackers/x11-tracker.test.js",
"test:smoke:dist": "bun run test:config:smoke:dist && bun run test:core:smoke:dist", "test:smoke:dist": "bun run test:config:smoke:dist && bun run test:core:smoke:dist",
"test:subtitle:dist": "echo \"Subtitle tests are currently not configured\"", "test:subtitle:dist": "echo \"Subtitle tests are currently not configured\"",

63
scripts/dev-watch.sh Executable file
View File

@@ -0,0 +1,63 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$ROOT_DIR"
electron_args=("$@")
if [[ ${#electron_args[@]} -eq 0 ]]; then
electron_args=(--start --dev)
fi
if ! command -v bun >/dev/null 2>&1; then
echo "[ERROR] bun not found in PATH" >&2
exit 1
fi
TS_WATCH_PID=""
RENDER_WATCH_PID=""
cleanup() {
local pids=("$TS_WATCH_PID" "$RENDER_WATCH_PID")
for pid in "${pids[@]}"; do
if [[ -n "$pid" ]] && kill -0 "$pid" 2>/dev/null; then
kill "$pid" 2>/dev/null || true
fi
done
}
trap cleanup EXIT INT TERM
sync_renderer_assets() {
mkdir -p dist/renderer
cp src/renderer/index.html src/renderer/style.css dist/renderer/
mkdir -p dist/renderer/fonts
cp -R src/renderer/fonts/. dist/renderer/fonts/
}
echo "[INFO] Syncing renderer static assets"
sync_renderer_assets
echo "[INFO] Running initial compile"
bun run tsc
bun run build:renderer
echo "[INFO] Starting TypeScript watch"
bun run tsc --watch --preserveWatchOutput &
TS_WATCH_PID=$!
echo "[INFO] Starting renderer watch"
bunx esbuild src/renderer/renderer.ts \
--bundle \
--platform=browser \
--format=esm \
--target=es2022 \
--outfile=dist/renderer/renderer.js \
--sourcemap \
--watch &
RENDER_WATCH_PID=$!
echo "[INFO] Launching Electron with args: ${electron_args[*]}"
bun run electron . "${electron_args[@]}"

8
scripts/subminer-dev.sh Executable file
View File

@@ -0,0 +1,8 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$ROOT_DIR"
exec bun run electron . "$@"

View File

@@ -100,6 +100,70 @@ export function createMouseHandlers(
return null; 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( function getWordBoundsAtOffset(
text: string, text: string,
offset: number, offset: number,
@@ -218,18 +282,8 @@ export function createMouseHandlers(
}; };
ctx.dom.subtitleRoot.addEventListener('mousemove', (event: MouseEvent) => { ctx.dom.subtitleRoot.addEventListener('mousemove', (event: MouseEvent) => {
if (!(event.target instanceof Element)) { const tokenIndex = resolveHoveredInvisibleTokenIndex(event);
queueNullHoveredToken(); if (tokenIndex === null) {
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) {
queueNullHoveredToken(); queueNullHoveredToken();
return; return;
} }

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

View File

@@ -39,6 +39,21 @@ export function calculateOsdScale(
? ratios.reduce((sum, value) => sum + value, 0) / ratios.length ? ratios.reduce((sum, value) => sum + value, 0) / ratios.length
: devicePixelRatio; : 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; return avgRatio > 1.25 ? avgRatio : 1;
} }
@@ -67,7 +82,7 @@ export function applyPlatformFontCompensation(
fontSizePx: number, fontSizePx: number,
isMacOSPlatform: boolean, isMacOSPlatform: boolean,
): number { ): number {
return isMacOSPlatform ? fontSizePx * 0.87 : fontSizePx; return isMacOSPlatform ? fontSizePx * 0.82 : fontSizePx;
} }
function calculateGeometry( function calculateGeometry(
@@ -82,11 +97,11 @@ function calculateGeometry(
const videoTopInset = dims ? dims.mt / osdToCssScale : 0; const videoTopInset = dims ? dims.mt / osdToCssScale : 0;
const videoBottomInset = dims ? dims.mb / osdToCssScale : 0; const videoBottomInset = dims ? dims.mb / osdToCssScale : 0;
const anchorToVideoArea = !metrics.subUseMargins; // Keep layout anchored to the same drawable video region represented by osd-dimensions.
const leftInset = anchorToVideoArea ? videoLeftInset : 0; const leftInset = videoLeftInset;
const rightInset = anchorToVideoArea ? videoRightInset : 0; const rightInset = videoRightInset;
const topInset = anchorToVideoArea ? videoTopInset : 0; const topInset = videoTopInset;
const bottomInset = anchorToVideoArea ? videoBottomInset : 0; const bottomInset = videoBottomInset;
const horizontalAvailable = Math.max(0, renderAreaWidth - leftInset - rightInset); const horizontalAvailable = Math.max(0, renderAreaWidth - leftInset - rightInset);
return { return {
@@ -112,7 +127,9 @@ export function calculateSubtitleMetrics(
window.devicePixelRatio || 1, window.devicePixelRatio || 1,
); );
const geometry = calculateGeometry(metrics, osdToCssScale); 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 scaleRefHeight = metrics.subScaleByWindow ? geometry.renderAreaHeight : videoHeight;
const pxPerScaledPixel = Math.max(0.1, scaleRefHeight / 720); const pxPerScaledPixel = Math.max(0.1, scaleRefHeight / 720);
const computedFontSize = const computedFontSize =

View File

@@ -47,24 +47,24 @@ export function createMpvSubtitleLayoutController(
hAlign: alignment.hAlign, hAlign: alignment.hAlign,
}); });
applyTypography(ctx, {
metrics,
pxPerScaledPixel: geometry.pxPerScaledPixel,
effectiveFontSize: geometry.effectiveFontSize,
});
applyVerticalPosition(ctx, { applyVerticalPosition(ctx, {
metrics, metrics,
renderAreaHeight: geometry.renderAreaHeight, renderAreaHeight: geometry.renderAreaHeight,
topInset: geometry.topInset, topInset: geometry.topInset,
bottomInset: geometry.bottomInset, bottomInset: geometry.bottomInset,
marginY: geometry.marginY, marginY: geometry.marginY,
effectiveFontSize: geometry.effectiveFontSize,
borderPx: effectiveBorderSize, borderPx: effectiveBorderSize,
shadowPx: effectiveShadowOffset, shadowPx: effectiveShadowOffset,
measuredDescentPx: ctx.state.invisibleMeasuredDescentPx,
vAlign: alignment.vAlign, vAlign: alignment.vAlign,
}); });
applyTypography(ctx, {
metrics,
pxPerScaledPixel: geometry.pxPerScaledPixel,
effectiveFontSize: geometry.effectiveFontSize,
});
ctx.state.invisibleLayoutBaseLeftPx = parseFloat(ctx.dom.subtitleContainer.style.left) || 0; ctx.state.invisibleLayoutBaseLeftPx = parseFloat(ctx.dom.subtitleContainer.style.left) || 0;
const parsedBottom = parseFloat(ctx.dom.subtitleContainer.style.bottom); const parsedBottom = parseFloat(ctx.dom.subtitleContainer.style.bottom);

View File

@@ -249,6 +249,12 @@ async function init(): Promise<void> {
lastSubtitlePreview = truncateForErrorLog(data.text); lastSubtitlePreview = truncateForErrorLog(data.text);
} }
subtitleRenderer.renderSubtitle(data); subtitleRenderer.renderSubtitle(data);
if (ctx.platform.isInvisibleLayer && ctx.state.mpvSubtitleRenderMetrics) {
positioning.applyInvisibleSubtitleLayoutFromMpvMetrics(
ctx.state.mpvSubtitleRenderMetrics,
'subtitle',
);
}
measurementReporter.schedule(); measurementReporter.schedule();
}); });
}); });

View File

@@ -72,6 +72,13 @@ export type RendererState = {
lastHoverSelectionKey: string; lastHoverSelectionKey: string;
lastHoverSelectionNode: Text | null; lastHoverSelectionNode: Text | null;
lastHoveredTokenIndex: number | null; lastHoveredTokenIndex: number | null;
invisibleTokenHoverSourceText: string;
invisibleTokenHoverRanges: Array<{
start: number;
end: number;
tokenIndex: number;
}>;
invisibleMeasuredDescentPx: number | null;
knownWordColor: string; knownWordColor: string;
nPlusOneColor: string; nPlusOneColor: string;
@@ -150,6 +157,9 @@ export function createRendererState(): RendererState {
lastHoverSelectionKey: '', lastHoverSelectionKey: '',
lastHoverSelectionNode: null, lastHoverSelectionNode: null,
lastHoveredTokenIndex: null, lastHoveredTokenIndex: null,
invisibleTokenHoverSourceText: '',
invisibleTokenHoverRanges: [],
invisibleMeasuredDescentPx: null,
knownWordColor: '#a6da95', knownWordColor: '#a6da95',
nPlusOneColor: '#c6a0f6', nPlusOneColor: '#c6a0f6',

View File

@@ -483,7 +483,7 @@ body.layer-invisible.debug-invisible-visualization #subtitleRoot .word,
body.layer-invisible.debug-invisible-visualization #subtitleRoot .c { body.layer-invisible.debug-invisible-visualization #subtitleRoot .c {
color: #ed8796 !important; color: #ed8796 !important;
-webkit-text-fill-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; paint-order: stroke fill !important;
text-shadow: none !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 { body.layer-invisible.invisible-position-edit #subtitleRoot .c {
color: #ed8796 !important; color: #ed8796 !important;
-webkit-text-fill-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; paint-order: stroke fill !important;
text-shadow: none !important; text-shadow: none !important;
} }

View File

@@ -5,7 +5,13 @@ import path from 'node:path';
import type { MergedToken } from '../types'; import type { MergedToken } from '../types';
import { PartOfSpeech } from '../types.js'; 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 { function createToken(overrides: Partial<MergedToken>): MergedToken {
return { 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教団の主力は人もいない');
assert.deepEqual(ranges, [{ start: 0, end: 10, tokenIndex: 0 }]);
});
test('normalizeSubtitle collapses explicit line breaks when collapseLineBreaks is enabled', () => { test('normalizeSubtitle collapses explicit line breaks when collapseLineBreaks is enabled', () => {
assert.equal( assert.equal(
normalizeSubtitle('常人が使えば\\Nその圧倒的な力に\\n体が耐えきれず死に至るが…', true, true), 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', () => { test('JLPT CSS rules use underline-only styling in renderer stylesheet', () => {
const distCssPath = path.join(process.cwd(), 'dist', 'renderer', 'style.css'); const distCssPath = path.join(process.cwd(), 'dist', 'renderer', 'style.css');
const srcCssPath = path.join(process.cwd(), 'src', 'renderer', 'style.css'); const srcCssPath = path.join(process.cwd(), 'src', 'renderer', 'style.css');

View File

@@ -9,6 +9,19 @@ type FrequencyRenderSettings = {
bandedColors: [string, string, string, string, string]; 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 { function isWhitespaceOnly(value: string): boolean {
return value.trim().length === 0; return value.trim().length === 0;
} }
@@ -218,6 +231,40 @@ export function alignTokensToSourceText(
return segments; 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( export function computeWordClass(
token: MergedToken, token: MergedToken,
frequencySettings?: Partial<FrequencyRenderSettings>, frequencySettings?: Partial<FrequencyRenderSettings>,
@@ -312,27 +359,21 @@ export function createSubtitleRenderer(ctx: RendererContext) {
if (!text) return; if (!text) return;
if (ctx.platform.isInvisibleLayer) { if (ctx.platform.isInvisibleLayer) {
// Keep natural kerning/shaping in invisible layer to match mpv glyph placement.
const normalizedInvisible = normalizeSubtitle(text, false); const normalizedInvisible = normalizeSubtitle(text, false);
ctx.state.currentInvisibleSubtitleLineCount = Math.max( ctx.state.currentInvisibleSubtitleLineCount = Math.max(
1, 1,
normalizedInvisible.split('\n').length, normalizedInvisible.split('\n').length,
); );
if (tokens && tokens.length > 0) { ctx.state.invisibleTokenHoverSourceText = normalizedInvisible;
renderWithTokens( ctx.state.invisibleTokenHoverRanges =
ctx.dom.subtitleRoot, tokens && tokens.length > 0 ? buildInvisibleTokenHoverRanges(tokens, normalizedInvisible) : [];
tokens,
getFrequencyRenderSettings(),
text,
true,
);
} else {
renderPlainTextPreserveLineBreaks(ctx.dom.subtitleRoot, normalizedInvisible); renderPlainTextPreserveLineBreaks(ctx.dom.subtitleRoot, normalizedInvisible);
}
return; return;
} }
const normalized = normalizeSubtitle(text, true, !ctx.state.preserveSubtitleLineBreaks); const normalized = normalizeSubtitle(text, true, !ctx.state.preserveSubtitleLineBreaks);
if (tokens && tokens.length > 0) { if (shouldRenderTokenizedSubtitle(ctx.platform.isInvisibleLayer, tokens?.length ?? 0) && tokens) {
renderWithTokens( renderWithTokens(
ctx.dom.subtitleRoot, ctx.dom.subtitleRoot,
tokens, tokens,