diff --git a/src/renderer/index.html b/src/renderer/index.html index 1ee7f27..2b8b287 100644 --- a/src/renderer/index.html +++ b/src/renderer/index.html @@ -22,7 +22,7 @@ SubMiner diff --git a/src/renderer/renderer.ts b/src/renderer/renderer.ts index 09f3b38..76f2a76 100644 --- a/src/renderer/renderer.ts +++ b/src/renderer/renderer.ts @@ -31,19 +31,6 @@ interface SubtitleData { tokens: MergedToken[] | null; } -interface AssInlineOverrides { - fontFamily?: string; - fontSize?: number; - letterSpacing?: number; - scaleX?: number; - scaleY?: number; - bold?: boolean; - italic?: boolean; - borderSize?: number; - shadowOffset?: number; - alignment?: number; -} - interface MpvSubtitleRenderMetrics { subPos: number; subFontSize: number; @@ -349,6 +336,9 @@ const overlayLayer = : overlayLayerFromQuery; const isInvisibleLayer = overlayLayer === "invisible"; const isLinuxPlatform = navigator.platform.toLowerCase().includes("linux"); +const isMacOSPlatform = + navigator.platform.toLowerCase().includes("mac") || + /mac/i.test(navigator.userAgent); // Linux passthrough forwarding is not reliable for this overlay; keep pointer // routing local so hover lookup, drag-reposition, and key handling remain usable. const shouldToggleMouseIgnore = !isLinuxPlatform; @@ -403,7 +393,13 @@ const DEFAULT_MPV_SUBTITLE_RENDER_METRICS: MpvSubtitleRenderMetrics = { let mpvSubtitleRenderMetrics: MpvSubtitleRenderMetrics = { ...DEFAULT_MPV_SUBTITLE_RENDER_METRICS, }; -let currentSubtitleAss = ""; +let currentInvisibleSubtitleLineCount = 1; +let lastHoverSelectionKey = ""; +let lastHoverSelectionNode: Text | null = null; +const wordSegmenter = + typeof Intl !== "undefined" && "Segmenter" in Intl + ? new Intl.Segmenter(undefined, { granularity: "word" }) + : null; function isAnySettingsModalOpen(): boolean { return runtimeOptionsModalOpen || subsyncModalOpen || kikuModalOpen; @@ -502,6 +498,8 @@ function renderPlainTextPreserveLineBreaks(text: string): void { function renderSubtitle(data: SubtitleData | string): void { subtitleRoot.innerHTML = ""; + lastHoverSelectionKey = ""; + lastHoverSelectionNode = null; let text: string; let tokens: MergedToken[] | null; @@ -521,8 +519,13 @@ function renderSubtitle(data: SubtitleData | string): void { } if (isInvisibleLayer) { - // Keep natural kerning/shaping for accurate hitbox alignment with mpv/libass. - renderPlainTextPreserveLineBreaks(normalizeSubtitle(text, false)); + // Keep natural kerning/shaping for accurate hitbox alignment with mpv. + const normalizedInvisible = normalizeSubtitle(text, false); + currentInvisibleSubtitleLineCount = Math.max( + 1, + normalizedInvisible.split("\n").length, + ); + renderPlainTextPreserveLineBreaks(normalizedInvisible); return; } @@ -741,101 +744,6 @@ function sanitizeMpvSubtitleRenderMetrics( }; } -function getLastMatch(text: string, pattern: RegExp): string | undefined { - let last: string | undefined; - let match: RegExpExecArray | null; - // eslint-disable-next-line no-cond-assign - while ((match = pattern.exec(text)) !== null) { - last = match[1]; - } - return last; -} - -function parseAssInlineOverrides(assText: string): AssInlineOverrides { - if (!assText) return {}; - - const result: AssInlineOverrides = {}; - - const fontFamily = getLastMatch(assText, /\\fn([^\\}]+)/g); - if (fontFamily && fontFamily.trim()) { - result.fontFamily = fontFamily.trim(); - } - - const fontSize = getLastMatch(assText, /\\fs(-?\d+(?:\.\d+)?)/g); - if (fontSize) { - const parsed = Number.parseFloat(fontSize); - if (Number.isFinite(parsed) && parsed > 0) { - result.fontSize = parsed; - } - } - - const letterSpacing = getLastMatch(assText, /\\fsp(-?\d+(?:\.\d+)?)/g); - if (letterSpacing) { - const parsed = Number.parseFloat(letterSpacing); - if (Number.isFinite(parsed)) { - result.letterSpacing = parsed; - } - } - - const scaleX = getLastMatch(assText, /\\fscx(-?\d+(?:\.\d+)?)/g); - if (scaleX) { - const parsed = Number.parseFloat(scaleX); - if (Number.isFinite(parsed) && parsed > 0) { - result.scaleX = parsed / 100; - } - } - - const scaleY = getLastMatch(assText, /\\fscy(-?\d+(?:\.\d+)?)/g); - if (scaleY) { - const parsed = Number.parseFloat(scaleY); - if (Number.isFinite(parsed) && parsed > 0) { - result.scaleY = parsed / 100; - } - } - - const bold = getLastMatch(assText, /\\b(-?\d+)/g); - if (bold) { - const parsed = Number.parseInt(bold, 10); - if (!Number.isNaN(parsed)) { - result.bold = parsed !== 0; - } - } - - const italic = getLastMatch(assText, /\\i(-?\d+)/g); - if (italic) { - const parsed = Number.parseInt(italic, 10); - if (!Number.isNaN(parsed)) { - result.italic = parsed !== 0; - } - } - - const borderSize = getLastMatch(assText, /\\bord(-?\d+(?:\.\d+)?)/g); - if (borderSize) { - const parsed = Number.parseFloat(borderSize); - if (Number.isFinite(parsed) && parsed >= 0) { - result.borderSize = parsed; - } - } - - const shadowOffset = getLastMatch(assText, /\\shad(-?\d+(?:\.\d+)?)/g); - if (shadowOffset) { - const parsed = Number.parseFloat(shadowOffset); - if (Number.isFinite(parsed) && parsed >= 0) { - result.shadowOffset = parsed; - } - } - - const alignment = getLastMatch(assText, /\\an(\d)/g); - if (alignment) { - const parsed = Number.parseInt(alignment, 10); - if (parsed >= 1 && parsed <= 9) { - result.alignment = parsed; - } - } - - return result; -} - function applyInvisibleSubtitleLayoutFromMpvMetrics( metrics: Partial | null | undefined, source: string, @@ -843,52 +751,50 @@ function applyInvisibleSubtitleLayoutFromMpvMetrics( mpvSubtitleRenderMetrics = sanitizeMpvSubtitleRenderMetrics(metrics); const dims = mpvSubtitleRenderMetrics.osdDimensions; - const renderAreaHeight = dims?.h ?? window.innerHeight; - const renderAreaWidth = dims?.w ?? window.innerWidth; - const videoLeftInset = dims?.ml ?? 0; - const videoRightInset = dims?.mr ?? 0; - const videoTopInset = dims?.mt ?? 0; - const videoBottomInset = dims?.mb ?? 0; + const dpr = window.devicePixelRatio || 1; + // On macOS, mpv osd-dimensions can be reported in either physical pixels or + // point-like units. Infer the osd->CSS scale from current viewport ratio. + const osdToCssScale = + isMacOSPlatform && dims + ? (() => { + const ratios = [ + dims.w / Math.max(1, window.innerWidth), + dims.h / Math.max(1, window.innerHeight), + ].filter((value) => Number.isFinite(value) && value > 0); + const avgRatio = + ratios.length > 0 + ? ratios.reduce((sum, value) => sum + value, 0) / ratios.length + : dpr; + return avgRatio > 1.25 ? avgRatio : 1; + })() + : dpr; + const renderAreaHeight = dims ? dims.h / osdToCssScale : window.innerHeight; + const renderAreaWidth = dims ? dims.w / osdToCssScale : window.innerWidth; + const videoLeftInset = dims ? dims.ml / osdToCssScale : 0; + const videoRightInset = dims ? dims.mr / osdToCssScale : 0; + const videoTopInset = dims ? dims.mt / osdToCssScale : 0; + const videoBottomInset = dims ? dims.mb / osdToCssScale : 0; const anchorToVideoArea = !mpvSubtitleRenderMetrics.subUseMargins; const leftInset = anchorToVideoArea ? videoLeftInset : 0; const rightInset = anchorToVideoArea ? videoRightInset : 0; const topInset = anchorToVideoArea ? videoTopInset : 0; const bottomInset = anchorToVideoArea ? videoBottomInset : 0; - // Match mpv subtitle sizing from scaled pixels in mpv's OSD space. - const osdReferenceHeight = mpvSubtitleRenderMetrics.subScaleByWindow - ? mpvSubtitleRenderMetrics.osdHeight - : 720; - const pxPerScaledPixel = Math.max(0.1, renderAreaHeight / osdReferenceHeight); + const videoHeight = renderAreaHeight - videoTopInset - videoBottomInset; + const scaleRefHeight = mpvSubtitleRenderMetrics.subScaleByWindow + ? renderAreaHeight + : videoHeight; + const pxPerScaledPixel = Math.max(0.1, scaleRefHeight / 720); const computedFontSize = mpvSubtitleRenderMetrics.subFontSize * mpvSubtitleRenderMetrics.subScale * - pxPerScaledPixel; - const rawAssOverrides = parseAssInlineOverrides(currentSubtitleAss); + (isLinuxPlatform ? 1 : pxPerScaledPixel); - // When sub-ass-override is "yes" (default), "force", or "strip", mpv ignores - // ASS inline style tags (\fn, \fs, \b, \i, \bord, \shad, \fsp, \fscx, \fscy) - // and uses its own sub-* settings instead. Only positioning tags like \an and - // \pos are always respected. Since sub-text-ass returns the raw ASS text - // regardless of this setting, we must skip style overrides when mpv is - // overriding them. - const assOverrideMode = mpvSubtitleRenderMetrics.subAssOverride; - const mpvOverridesAssStyles = - assOverrideMode === "yes" || - assOverrideMode === "force" || - assOverrideMode === "strip"; - const assOverrides: AssInlineOverrides = mpvOverridesAssStyles - ? {} - : rawAssOverrides; - - const effectiveFontSize = - assOverrides.fontSize && assOverrides.fontSize > 0 - ? assOverrides.fontSize * pxPerScaledPixel - : computedFontSize; + const macOsFontCompensation = isMacOSPlatform ? 0.87 : 1; + const effectiveFontSize = computedFontSize * macOsFontCompensation; applySubtitleFontSize(effectiveFontSize); - // \an is a positioning tag — always respected regardless of sub-ass-override - const alignment = rawAssOverrides.alignment ?? 2; + const alignment = 2; const hAlign = ((alignment - 1) % 3) as 0 | 1 | 2; // 0=left, 1=center, 2=right const vAlign = Math.floor((alignment - 1) / 3) as 0 | 1 | 2; // 0=bottom, 1=middle, 2=top @@ -902,12 +808,19 @@ function applyInvisibleSubtitleLayoutFromMpvMetrics( renderAreaWidth - leftInset - rightInset - Math.round(marginX * 2), ); + const effectiveBorderSize = mpvSubtitleRenderMetrics.subBorderSize * pxPerScaledPixel; + document.documentElement.style.setProperty( + "--sub-border-size", + `${effectiveBorderSize}px`, + ); + subtitleContainer.style.position = "absolute"; subtitleContainer.style.maxWidth = `${horizontalAvailable}px`; subtitleContainer.style.width = `${horizontalAvailable}px`; subtitleContainer.style.padding = "0"; subtitleContainer.style.background = "transparent"; subtitleContainer.style.marginBottom = "0"; + subtitleContainer.style.pointerEvents = "none"; // Horizontal positioning based on \an alignment. // All alignments position the container at the left margin with full available @@ -916,47 +829,89 @@ function applyInvisibleSubtitleLayoutFromMpvMetrics( subtitleContainer.style.left = `${leftInset + marginX}px`; subtitleContainer.style.right = ""; subtitleContainer.style.transform = ""; + subtitleContainer.style.textAlign = ""; if (hAlign === 0) { + subtitleContainer.style.textAlign = "left"; subtitleRoot.style.textAlign = "left"; } else if (hAlign === 2) { + subtitleContainer.style.textAlign = "right"; subtitleRoot.style.textAlign = "right"; } else { + subtitleContainer.style.textAlign = "center"; subtitleRoot.style.textAlign = "center"; } + subtitleRoot.style.display = "inline-block"; + subtitleRoot.style.maxWidth = "100%"; + subtitleRoot.style.pointerEvents = "auto"; // Vertical positioning based on \an alignment + const lineCount = Math.max(1, currentInvisibleSubtitleLineCount); + const multiline = lineCount > 1; + const baselineCompensationFactor = + lineCount >= 3 ? 0.46 : multiline ? 0.58 : 0.7; + const baselineCompensationPx = Math.max( + 0, + effectiveFontSize * baselineCompensationFactor, + ); if (vAlign === 2) { - subtitleContainer.style.top = `${topInset + marginY}px`; + subtitleContainer.style.top = `${Math.max(0, topInset + marginY - baselineCompensationPx)}px`; subtitleContainer.style.bottom = ""; } else if (vAlign === 1) { subtitleContainer.style.top = "50%"; subtitleContainer.style.bottom = ""; subtitleContainer.style.transform = "translateY(-50%)"; } else { - const subPosOffset = + const subPosMargin = ((100 - mpvSubtitleRenderMetrics.subPos) / 100) * renderAreaHeight; - const bottomPx = Math.max(0, bottomInset + marginY + subPosOffset); + const effectiveMargin = Math.max(marginY, subPosMargin); + const bottomPx = Math.max( + 0, + bottomInset + effectiveMargin + baselineCompensationPx, + ); subtitleContainer.style.top = ""; subtitleContainer.style.bottom = `${bottomPx}px`; } + subtitleRoot.style.setProperty( + "line-height", + isMacOSPlatform + ? lineCount >= 3 + ? "1.18" + : multiline + ? "1.08" + : "0.86" + : "normal", + isMacOSPlatform ? "important" : "", + ); + + const rawFont = mpvSubtitleRenderMetrics.subFont; + const strippedFont = rawFont + .replace( + /\s+(Regular|Bold|Italic|Light|Medium|Semi\s*Bold|Extra\s*Bold|Extra\s*Light|Thin|Black|Heavy|Demi\s*Bold|Book|Condensed)\s*$/i, + "", + ) + .trim(); subtitleRoot.style.fontFamily = - assOverrides.fontFamily || mpvSubtitleRenderMetrics.subFont; - const effectiveSpacing = - typeof assOverrides.letterSpacing === "number" - ? assOverrides.letterSpacing - : mpvSubtitleRenderMetrics.subSpacing; - subtitleRoot.style.letterSpacing = + strippedFont !== rawFont + ? `"${rawFont}", "${strippedFont}", sans-serif` + : `"${rawFont}", sans-serif`; + const effectiveSpacing = mpvSubtitleRenderMetrics.subSpacing; + subtitleRoot.style.setProperty( + "letter-spacing", Math.abs(effectiveSpacing) > 0.0001 - ? `${effectiveSpacing * pxPerScaledPixel}px` - : "normal"; - const effectiveBold = assOverrides.bold ?? mpvSubtitleRenderMetrics.subBold; - const effectiveItalic = - assOverrides.italic ?? mpvSubtitleRenderMetrics.subItalic; + ? `${effectiveSpacing * pxPerScaledPixel * (isMacOSPlatform ? 0.7 : 1)}px` + : isMacOSPlatform + ? "-0.02em" + : "0px", + isMacOSPlatform ? "important" : "", + ); + subtitleRoot.style.fontKerning = isMacOSPlatform ? "auto" : "none"; + const effectiveBold = mpvSubtitleRenderMetrics.subBold; + const effectiveItalic = mpvSubtitleRenderMetrics.subItalic; subtitleRoot.style.fontWeight = effectiveBold ? "700" : "400"; subtitleRoot.style.fontStyle = effectiveItalic ? "italic" : "normal"; - const scaleX = assOverrides.scaleX ?? 1; - const scaleY = assOverrides.scaleY ?? 1; + const scaleX = 1; + const scaleY = 1; if (Math.abs(scaleX - 1) > 0.0001 || Math.abs(scaleY - 1) > 0.0001) { subtitleRoot.style.transform = `scale(${scaleX}, ${scaleY})`; subtitleRoot.style.transformOrigin = "50% 100%"; @@ -968,7 +923,7 @@ function applyInvisibleSubtitleLayoutFromMpvMetrics( // CSS line-height: normal adds "half-leading" — extra space equally distributed // above and below text within each line box. This means the text's visual bottom // is above the element's bottom edge by halfLeading pixels. We must compensate - // by shifting the element down by that amount so glyph positions match mpv/libass. + // by shifting the element down by that amount so glyph positions better match mpv. const computedLineHeight = parseFloat( getComputedStyle(subtitleRoot).lineHeight, ); @@ -989,12 +944,7 @@ function applyInvisibleSubtitleLayoutFromMpvMetrics( } } } - console.log( - "[invisible-overlay] Applied mpv subtitle render metrics from", - source, - mpvSubtitleRenderMetrics, - assOverrides, - ); + console.log("[invisible-overlay] Applied mpv subtitle render metrics from", source); } function setJimakuStatus(message: string, isError = false): void { @@ -1919,6 +1869,129 @@ function setupDragging(): void { }); } +function getCaretTextPointRange(clientX: number, clientY: number): Range | null { + const documentWithCaretApi = document as Document & { + caretRangeFromPoint?: (x: number, y: number) => Range | null; + caretPositionFromPoint?: ( + x: number, + y: number, + ) => { offsetNode: Node; offset: number } | null; + }; + + if (typeof documentWithCaretApi.caretRangeFromPoint === "function") { + return documentWithCaretApi.caretRangeFromPoint(clientX, clientY); + } + + if (typeof documentWithCaretApi.caretPositionFromPoint === "function") { + const caretPosition = documentWithCaretApi.caretPositionFromPoint( + clientX, + clientY, + ); + if (!caretPosition) return null; + const range = document.createRange(); + range.setStart(caretPosition.offsetNode, caretPosition.offset); + range.collapse(true); + return range; + } + + return null; +} + +function getWordBoundsAtOffset( + text: string, + offset: number, +): { start: number; end: number } | null { + if (!text || text.length === 0) return null; + + const clampedOffset = Math.max(0, Math.min(offset, text.length)); + const probeIndex = + clampedOffset >= text.length ? Math.max(0, text.length - 1) : clampedOffset; + + if (wordSegmenter) { + for (const part of wordSegmenter.segment(text)) { + const start = part.index; + const end = start + part.segment.length; + if (probeIndex >= start && probeIndex < end) { + if (part.isWordLike === false) return null; + return { start, end }; + } + } + } + + const isBoundary = (char: string): boolean => + /[\s\u3000.,!?;:()[\]{}"'`~<>/\\|@#$%^&*+=\-、。・「」『』【】〈〉《》]/.test( + char, + ); + + const probeChar = text[probeIndex]; + if (!probeChar || isBoundary(probeChar)) return null; + + let start = probeIndex; + while (start > 0 && !isBoundary(text[start - 1])) { + start -= 1; + } + + let end = probeIndex + 1; + while (end < text.length && !isBoundary(text[end])) { + end += 1; + } + + if (end <= start) return null; + return { start, end }; +} + +function updateHoverWordSelection(event: MouseEvent): void { + if (!isInvisibleLayer || !isMacOSPlatform) return; + if (event.buttons !== 0) return; + if (!(event.target instanceof Node)) return; + if (!subtitleRoot.contains(event.target)) return; + + const caretRange = getCaretTextPointRange(event.clientX, event.clientY); + if (!caretRange) return; + if (caretRange.startContainer.nodeType !== Node.TEXT_NODE) return; + if (!subtitleRoot.contains(caretRange.startContainer)) return; + + const textNode = caretRange.startContainer as Text; + const wordBounds = getWordBoundsAtOffset(textNode.data, caretRange.startOffset); + if (!wordBounds) return; + + const selectionKey = `${wordBounds.start}:${wordBounds.end}:${textNode.data.slice( + wordBounds.start, + wordBounds.end, + )}`; + if ( + selectionKey === lastHoverSelectionKey && + textNode === lastHoverSelectionNode + ) { + return; + } + + const selection = window.getSelection(); + if (!selection) return; + + const range = document.createRange(); + range.setStart(textNode, wordBounds.start); + range.setEnd(textNode, wordBounds.end); + + selection.removeAllRanges(); + selection.addRange(range); + lastHoverSelectionKey = selectionKey; + lastHoverSelectionNode = textNode; +} + +function setupInvisibleHoverSelection(): void { + if (!isInvisibleLayer || !isMacOSPlatform) return; + + subtitleRoot.addEventListener("mousemove", (event: MouseEvent) => { + updateHoverWordSelection(event); + }); + + subtitleRoot.addEventListener("mouseleave", () => { + lastHoverSelectionKey = ""; + lastHoverSelectionNode = null; + }); +} + function isInteractiveTarget(target: EventTarget | null): boolean { if (!(target instanceof Element)) return false; if (target.closest(".modal")) return true; @@ -2267,16 +2340,6 @@ async function init(): Promise { renderSubtitle(data); }); - if (isInvisibleLayer) { - window.electronAPI.onSubtitleAss((assText: string) => { - currentSubtitleAss = assText || ""; - applyInvisibleSubtitleLayoutFromMpvMetrics( - mpvSubtitleRenderMetrics, - "subtitle-ass", - ); - }); - } - if (!isInvisibleLayer) { window.electronAPI.onSubtitlePosition( (position: SubtitlePosition | null) => { @@ -2296,9 +2359,6 @@ async function init(): Promise { }); } - if (isInvisibleLayer) { - currentSubtitleAss = await window.electronAPI.getCurrentSubtitleAss(); - } const initialSubtitle = await window.electronAPI.getCurrentSubtitle(); renderSubtitle(initialSubtitle); @@ -2316,8 +2376,10 @@ async function init(): Promise { const initialSecondary = await window.electronAPI.getCurrentSecondarySub(); renderSecondarySub(initialSecondary); - subtitleContainer.addEventListener("mouseenter", handleMouseEnter); - subtitleContainer.addEventListener("mouseleave", handleMouseLeave); + const hoverTarget = isInvisibleLayer ? subtitleRoot : subtitleContainer; + hoverTarget.addEventListener("mouseenter", handleMouseEnter); + hoverTarget.addEventListener("mouseleave", handleMouseLeave); + setupInvisibleHoverSelection(); secondarySubContainer.addEventListener("mouseenter", handleMouseEnter); secondarySubContainer.addEventListener("mouseleave", handleMouseLeave); diff --git a/src/renderer/style.css b/src/renderer/style.css index 3e1d13b..e03b344 100644 --- a/src/renderer/style.css +++ b/src/renderer/style.css @@ -34,6 +34,7 @@ body { } #overlay { + position: relative; width: 100%; height: 100%; display: flex; @@ -300,6 +301,8 @@ body.layer-invisible #subtitleContainer { border: 0 !important; padding: 0 !important; border-radius: 0 !important; + position: relative; + z-index: 3; } body.layer-invisible #subtitleRoot, @@ -342,10 +345,9 @@ 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: 0.55px rgba(0, 0, 0, 0.95) !important; - text-shadow: - 0 0 8px rgba(237, 135, 150, 0.9), - 0 2px 6px rgba(0, 0, 0, 0.95) !important; + -webkit-text-stroke: calc(var(--sub-border-size, 2px) * 2) rgba(0, 0, 0, 0.85) !important; + paint-order: stroke fill !important; + text-shadow: none !important; } #secondarySubContainer {