From efaf9a78cdc95447328911c180d9246e109a0914 Mon Sep 17 00:00:00 2001 From: sudacode Date: Wed, 25 Feb 2026 00:44:25 -0800 Subject: [PATCH] fix(renderer): calibrate invisible overlay metrics and hover mapping --- Makefile | 10 +- docs/development.md | 9 ++ package.json | 4 +- scripts/dev-watch.sh | 63 ++++++++++ scripts/subminer-dev.sh | 8 ++ src/renderer/handlers/mouse.ts | 78 ++++++++++-- .../invisible-layout-metrics.test.ts | 112 ++++++++++++++++++ .../positioning/invisible-layout-metrics.ts | 31 +++-- src/renderer/positioning/invisible-layout.ts | 14 +-- src/renderer/renderer.ts | 6 + src/renderer/state.ts | 10 ++ src/renderer/style.css | 4 +- src/renderer/subtitle-render.test.ts | 40 ++++++- src/renderer/subtitle-render.ts | 65 ++++++++-- 14 files changed, 410 insertions(+), 44 deletions(-) create mode 100755 scripts/dev-watch.sh create mode 100755 scripts/subminer-dev.sh create mode 100644 src/renderer/positioning/invisible-layout-metrics.test.ts diff --git a/Makefile b/Makefile index 6425784..a396291 100644 --- a/Makefile +++ b/Makefile @@ -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 THEME_SOURCE := assets/themes/subminer.rasi @@ -53,6 +53,8 @@ help: " clean Remove build artifacts (dist/, release/, AppImage, binary)" \ " dev-start Build and launch local Electron app" \ " 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-stop Stop a running local Electron app" \ " docs-dev Run VitePress docs dev server" \ @@ -173,6 +175,12 @@ dev-start-macos: ensure-bun @bun run build @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 @bun run electron . --toggle diff --git a/docs/development.md b/docs/development.md index 1a8b3ba..50b9432 100644 --- a/docs/development.md +++ b/docs/development.md @@ -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 . --background # tray/background mode, minimal default logging 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 diff --git a/package.json b/package.json index cc71d0f..4f239b8 100644 --- a/package.json +++ b/package.json @@ -21,8 +21,8 @@ "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: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: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: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/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:smoke:dist": "bun run test:config:smoke:dist && bun run test:core:smoke:dist", "test:subtitle:dist": "echo \"Subtitle tests are currently not configured\"", diff --git a/scripts/dev-watch.sh b/scripts/dev-watch.sh new file mode 100755 index 0000000..9213dc3 --- /dev/null +++ b/scripts/dev-watch.sh @@ -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[@]}" diff --git a/scripts/subminer-dev.sh b/scripts/subminer-dev.sh new file mode 100755 index 0000000..ff853f3 --- /dev/null +++ b/scripts/subminer-dev.sh @@ -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 . "$@" diff --git a/src/renderer/handlers/mouse.ts b/src/renderer/handlers/mouse.ts index b5095a1..e5da966 100644 --- a/src/renderer/handlers/mouse.ts +++ b/src/renderer/handlers/mouse.ts @@ -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('.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; } diff --git a/src/renderer/positioning/invisible-layout-metrics.test.ts b/src/renderer/positioning/invisible-layout-metrics.test.ts new file mode 100644 index 0000000..10eddcd --- /dev/null +++ b/src/renderer/positioning/invisible-layout-metrics.test.ts @@ -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); +}); diff --git a/src/renderer/positioning/invisible-layout-metrics.ts b/src/renderer/positioning/invisible-layout-metrics.ts index bb0d0e0..118eda8 100644 --- a/src/renderer/positioning/invisible-layout-metrics.ts +++ b/src/renderer/positioning/invisible-layout-metrics.ts @@ -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 = diff --git a/src/renderer/positioning/invisible-layout.ts b/src/renderer/positioning/invisible-layout.ts index 792c043..2523009 100644 --- a/src/renderer/positioning/invisible-layout.ts +++ b/src/renderer/positioning/invisible-layout.ts @@ -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); diff --git a/src/renderer/renderer.ts b/src/renderer/renderer.ts index 6e88923..0179901 100644 --- a/src/renderer/renderer.ts +++ b/src/renderer/renderer.ts @@ -249,6 +249,12 @@ async function init(): Promise { lastSubtitlePreview = truncateForErrorLog(data.text); } subtitleRenderer.renderSubtitle(data); + if (ctx.platform.isInvisibleLayer && ctx.state.mpvSubtitleRenderMetrics) { + positioning.applyInvisibleSubtitleLayoutFromMpvMetrics( + ctx.state.mpvSubtitleRenderMetrics, + 'subtitle', + ); + } measurementReporter.schedule(); }); }); diff --git a/src/renderer/state.ts b/src/renderer/state.ts index add747c..860877a 100644 --- a/src/renderer/state.ts +++ b/src/renderer/state.ts @@ -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', diff --git a/src/renderer/style.css b/src/renderer/style.css index b31787c..f1153a6 100644 --- a/src/renderer/style.css +++ b/src/renderer/style.css @@ -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; } diff --git a/src/renderer/subtitle-render.test.ts b/src/renderer/subtitle-render.test.ts index f257368..940d113 100644 --- a/src/renderer/subtitle-render.test.ts +++ b/src/renderer/subtitle-render.test.ts @@ -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 { 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'); diff --git a/src/renderer/subtitle-render.ts b/src/renderer/subtitle-render.ts index 9498f2b..66592ce 100644 --- a/src/renderer/subtitle-render.ts +++ b/src/renderer/subtitle-render.ts @@ -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, @@ -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,