mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-02-27 18:22:41 -08:00
fix(renderer): calibrate invisible overlay metrics and hover mapping
This commit is contained in:
10
Makefile
10
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
|
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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
63
scripts/dev-watch.sh
Executable 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
8
scripts/subminer-dev.sh
Executable 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 . "$@"
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
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
|
? 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 =
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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教団の主力は1人もいない');
|
||||||
|
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');
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user