From 5cc35ad8bb04d8fbdefaa9c7385bf13b8c879b00 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=97=B0=EC=9A=B0?= Date: Sun, 21 Jun 2026 22:18:43 +0900 Subject: [PATCH 1/6] =?UTF-8?q?fix:=20=EC=9D=BC=EA=B4=84=20=EC=84=A0?= =?UTF-8?q?=ED=83=9D=20=EC=8B=9C=20=EB=85=B8=ED=8A=B8=20=ED=85=8C=EB=91=90?= =?UTF-8?q?=EB=A6=AC=20=EC=83=89=EC=9D=B4=20=EC=A0=9C=EB=8C=80=EB=A1=9C=20?= =?UTF-8?q?=ED=91=9C=EC=8B=9C=C2=B7=EB=AF=B8=EB=A6=AC=EB=B3=B4=EA=B8=B0?= =?UTF-8?q?=EB=90=98=EC=A7=80=20=EC=95=8A=EB=8A=94=20=EB=B2=84=EA=B7=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 배치 테두리 스와치가 피커가 닫혀 있을 때도 실제 공통값이 아닌 직전 로컬 상태(batchLocalColors.borderColor)를 표시하던 문제 수정. 노트색/ 글로우색과 동일하게 getBatchBorderColorDisplay 헬퍼로 공통값/Mixed를 표시하도록 통일. 또한 색 변경 중 라이브 미리보기 dispatch가 누락돼 있어 선택 색이 즉시 반영되지 않던 것도 수정 --- .../components/main/Grid/PropertiesPanel.tsx | 21 ++++++++++++ .../batch/BatchNoteTabContent.tsx | 18 +++++----- .../batch/BatchSelectionPanel.tsx | 33 ++++++++++++++++++- 3 files changed, 61 insertions(+), 11 deletions(-) diff --git a/src/renderer/components/main/Grid/PropertiesPanel.tsx b/src/renderer/components/main/Grid/PropertiesPanel.tsx index da083bcb..9ad29eea 100644 --- a/src/renderer/components/main/Grid/PropertiesPanel.tsx +++ b/src/renderer/components/main/Grid/PropertiesPanel.tsx @@ -1745,6 +1745,17 @@ const PropertiesPanel: React.FC = ({ dispatchKeyOnlyBatchUpdates(updates, 'commit'); }; + const handleBatchKeyOnlyStyleChange = ( + property: keyof KeyPosition, + value: KeyPosition[keyof KeyPosition], + ) => { + const updates = getSelectedKeyOnlyPositions().map(({ index }) => ({ + index, + [property]: value, + })) as Array<{ index: number } & Partial>; + dispatchKeyOnlyBatchUpdates(updates, 'preview'); + }; + const handleBatchNoteColorChangeKeysOnly = (value: NoteColor) => { const updates = getSelectedKeyOnlyPositions().map(({ index }) => ({ index, @@ -2133,6 +2144,16 @@ const PropertiesPanel: React.FC = ({ } else { handleBatchGlowColorChange(newColor); } + } else if (batchPickerFor === 'borderColor') { + // noteBorderColor는 #RRGGBB 계약 — 피커 출력을 hex로 정규화 후 라이브 preview + const solidColor = toRgbHexColor( + typeof newColor === 'string' ? newColor : undefined, + ); + if (selectedKeyElements.length > 0 && selectedStatElements.length > 0) { + handleBatchKeyOnlyStyleChange('noteBorderColor', solidColor); + } else { + handleBatchStyleChange('noteBorderColor', solidColor); + } } }; diff --git a/src/renderer/components/main/Grid/PropertiesPanel/batch/BatchNoteTabContent.tsx b/src/renderer/components/main/Grid/PropertiesPanel/batch/BatchNoteTabContent.tsx index e70f3262..c686660b 100644 --- a/src/renderer/components/main/Grid/PropertiesPanel/batch/BatchNoteTabContent.tsx +++ b/src/renderer/components/main/Grid/PropertiesPanel/batch/BatchNoteTabContent.tsx @@ -33,6 +33,11 @@ interface BatchNoteTabContentProps { label: string; isMixed: boolean; }; + getBatchBorderColorDisplay: () => { + style: React.CSSProperties; + label: string; + isMixed: boolean; + }; // 컬러 피커 토글 onNoteColorPickerToggle: () => void; onGlowColorPickerToggle: () => void; @@ -43,7 +48,6 @@ interface BatchNoteTabContentProps { batchNoteColorButtonRef: React.RefObject; batchGlowColorButtonRef: React.RefObject; batchBorderColorButtonRef: React.RefObject; - batchLocalColors: { borderColor: string }; // 번역 t: (key: string) => string; } @@ -53,6 +57,7 @@ const BatchNoteTabContent: React.FC = ({ handleBatchStyleChangeComplete, getBatchNoteColorDisplay, getBatchGlowColorDisplay, + getBatchBorderColorDisplay, onNoteColorPickerToggle, onGlowColorPickerToggle, onBorderColorPickerToggle, @@ -62,7 +67,6 @@ const BatchNoteTabContent: React.FC = ({ batchNoteColorButtonRef, batchGlowColorButtonRef, batchBorderColorButtonRef, - batchLocalColors, t, }) => { const { noteEffect: _noteEffect } = useSettingsStore(); @@ -219,14 +223,8 @@ const BatchNoteTabContent: React.FC = ({ ? 'border-[#459BF8]' : 'border-[#3A3943] hover:border-[#505058]' }`} - style={{ - backgroundColor: getMixedValue( - (pos) => pos.noteBorderColor, - '#FFFFFF', - ).isMixed - ? undefined - : batchLocalColors.borderColor, - }} + style={getBatchBorderColorDisplay().style} + title={getBatchBorderColorDisplay().label} /> = ({ }; }; + // 테두리 색은 단색만 지원. 피커 열림 시 로컬값, 닫힘 시 실제 공통값/Mixed 표시 + const getBatchBorderColorDisplay = () => { + if (batchPickerFor === 'borderColor') { + const color = batchLocalColors.borderColor; + return { + style: { backgroundColor: color }, + label: color.replace(/^#/, ''), + isMixed: false, + }; + } + + const mixedFn = + selectedKeyElements.length > 0 ? getMixedValueKeysOnly : getMixedValue; + const { isMixed, value } = mixedFn( + (pos) => pos.noteBorderColor, + '#FFFFFF', + ); + if (isMixed) + return { + style: { backgroundColor: '#666' }, + label: 'Mixed', + isMixed: true, + }; + const color = typeof value === 'string' ? value : '#FFFFFF'; + return { + style: { backgroundColor: color }, + label: color.replace(/^#/, ''), + isMixed: false, + }; + }; + const keysData = getSelectedKeysData(); const batchCounterSettings = keysData[0]?.position ? normalizeCounterSettings(keysData[0].position.counter) @@ -757,6 +788,7 @@ export const BatchKeyLikePanel: React.FC = ({ } getBatchNoteColorDisplay={getBatchNoteColorDisplay} getBatchGlowColorDisplay={getBatchGlowColorDisplay} + getBatchBorderColorDisplay={getBatchBorderColorDisplay} onNoteColorPickerToggle={() => handleBatchPickerToggle('noteColor') } @@ -772,7 +804,6 @@ export const BatchKeyLikePanel: React.FC = ({ batchNoteColorButtonRef={batchNoteColorButtonRef} batchGlowColorButtonRef={batchGlowColorButtonRef} batchBorderColorButtonRef={batchBorderColorButtonRef} - batchLocalColors={batchLocalColors} t={t} /> From edb634848f7eb605488fb5ef3718fa141dd903e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=97=B0=EC=9A=B0?= Date: Sun, 21 Jun 2026 22:34:23 +0900 Subject: [PATCH 2/6] =?UTF-8?q?fix:=20=EB=85=B8=ED=8A=B8=20=ED=85=8C?= =?UTF-8?q?=EB=91=90=EB=A6=AC=20=ED=88=AC=EB=AA=85=EB=8F=84=EB=A5=BC=20?= =?UTF-8?q?=EB=85=B8=ED=8A=B8=20=EB=B0=B0=EA=B2=BD=20=ED=88=AC=EB=AA=85?= =?UTF-8?q?=EB=8F=84=EC=99=80=20=EB=8F=85=EB=A6=BD=20=EC=A0=9C=EC=96=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 테두리 색이 #RRGGBB hex만 저장돼 색 피커의 알파가 버려지고, 셰이더가 borderAlpha = baseColor.a * borderMask로 계산해 테두리 투명도가 노트 배경 투명도에 종속되던 문제 수정. 테두리만 있는 직사각형(배경 투명+테두리 불투명)을 만들 수 없었음 - noteBorderOpacity(0~100) 필드 추가: Rust 모델(기본 100), zod, 노트 버퍼의 별도 noteBorderOpacity attribute, WebGL 셰이더에 독립 알파 채널 - 셰이더 borderAlpha를 baseColor.a 대신 vBorderOpacity 사용 - 테두리 색 피커의 알파 슬라이더 값을 버리지 않고 noteBorderOpacity로 캡처 (단일/배치 모두). 색은 hex 계약 유지 - colorUtils에 parseAlphaPercent/hexWithAlphaPercent 헬퍼 + 테스트 --- src-tauri/src/models/mod.rs | 7 +++ .../components/main/Grid/PropertiesPanel.tsx | 55 +++++++++++++------ .../batch/BatchNoteTabContent.tsx | 8 +-- .../batch/BatchSelectionPanel.tsx | 14 +++-- .../PropertiesPanel/single/NoteTabContent.tsx | 37 ++++++++++--- .../components/overlay/WebGLTracksOGL.tsx | 15 ++++- .../hooks/shared/useLayoutComputation.ts | 1 + src/renderer/stores/signals/noteBuffer.ts | 22 ++++++++ src/renderer/utils/color/colorUtils.test.ts | 33 +++++++++++ src/renderer/utils/color/colorUtils.ts | 24 ++++++++ src/types/key/keys.ts | 2 + 11 files changed, 183 insertions(+), 35 deletions(-) diff --git a/src-tauri/src/models/mod.rs b/src-tauri/src/models/mod.rs index 231a68d9..a0e9789b 100644 --- a/src-tauri/src/models/mod.rs +++ b/src-tauri/src/models/mod.rs @@ -237,6 +237,9 @@ pub struct KeyPosition { /// 노트 테두리 색상 #[serde(default)] pub note_border_color: Option, + /// 노트 테두리 투명도 (0~100). 노트 배경 투명도와 독립. 기본 100. + #[serde(default = "default_note_border_opacity")] + pub note_border_opacity: u32, /// 노트 테두리 방향 (all/vertical/horizontal) #[serde(default)] pub note_border_side: Option, @@ -763,6 +766,10 @@ fn default_note_glow_enabled() -> bool { fn default_note_glow_size() -> u32 { 20 } + +fn default_note_border_opacity() -> u32 { + 100 +} fn default_note_glow_opacity() -> u32 { 70 } diff --git a/src/renderer/components/main/Grid/PropertiesPanel.tsx b/src/renderer/components/main/Grid/PropertiesPanel.tsx index 9ad29eea..7997e596 100644 --- a/src/renderer/components/main/Grid/PropertiesPanel.tsx +++ b/src/renderer/components/main/Grid/PropertiesPanel.tsx @@ -11,7 +11,11 @@ import { usePropertiesPanelStore } from '@stores/grid/usePropertiesPanelStore'; import { useLayerGroupStore } from '@stores/data/useLayerGroupStore'; import { getKeyInfoByGlobalKey } from '@utils/core/KeyMaps'; import { translatePluginMessage } from '@utils/plugin/pluginI18n'; -import { toRgbHexColor } from '@utils/color/colorUtils'; +import { + toRgbHexColor, + parseAlphaPercent, + hexWithAlphaPercent, +} from '@utils/color/colorUtils'; import type { KeyPosition } from '@src/types/key/keys'; import type { StatItemPosition, StatItemType } from '@src/types/key/statItems'; import type { GraphItemPosition } from '@src/types/key/graphItems'; @@ -619,7 +623,13 @@ const PropertiesPanel: React.FC = ({ }); // 배치 편집용 로컬 ColorPicker 상태 - type BatchPickerTarget = 'noteColor' | 'glowColor' | 'borderColor' | 'fill' | 'stroke' | null; + type BatchPickerTarget = + | 'noteColor' + | 'glowColor' + | 'borderColor' + | 'fill' + | 'stroke' + | null; const [batchPickerFor, setBatchPickerFor] = useState(null); const [batchCounterColorState, setBatchCounterColorState] = useState< 'idle' | 'active' @@ -629,6 +639,7 @@ const PropertiesPanel: React.FC = ({ noteColor: NoteColor; glowColor: NoteColor; borderColor: string; + borderOpacity: number; fillIdle: string; fillActive: string; strokeIdle: string; @@ -637,6 +648,7 @@ const PropertiesPanel: React.FC = ({ noteColor: '#FFFFFF', glowColor: '#FFFFFF', borderColor: '#FFFFFF', + borderOpacity: 100, fillIdle: '#FFFFFF', fillActive: '#FFFFFF', strokeIdle: '#000000', @@ -1893,7 +1905,8 @@ const PropertiesPanel: React.FC = ({ ? schemaValue.default : 0; // step 값에서 소수 자릿수 자동 추론 - const stepStr = schemaValue.step != null ? String(schemaValue.step) : ''; + const stepStr = + schemaValue.step != null ? String(schemaValue.step) : ''; const dotIdx = stepStr.indexOf('.'); const hasDecimal = dotIdx !== -1; const decimalScale = hasDecimal ? stepStr.length - dotIdx - 1 : 0; @@ -2030,6 +2043,7 @@ const PropertiesPanel: React.FC = ({ return typeof gc === 'string' ? gc : '#FFFFFF'; })(), borderColor: firstPos.noteBorderColor ?? '#FFFFFF', + borderOpacity: firstPos.noteBorderOpacity ?? 100, fillIdle: counterSettings.fill.idle, fillActive: counterSettings.fill.active, strokeIdle: counterSettings.stroke.idle, @@ -2057,7 +2071,10 @@ const PropertiesPanel: React.FC = ({ case 'glowColor': return batchLocalColors.glowColor; case 'borderColor': - return batchLocalColors.borderColor; + return hexWithAlphaPercent( + batchLocalColors.borderColor, + batchLocalColors.borderOpacity, + ); case 'fill': return batchCounterColorState === 'active' ? batchLocalColors.fillActive @@ -2097,10 +2114,11 @@ const PropertiesPanel: React.FC = ({ [batchPickerFor]: newColor, })); } else if (batchPickerFor === 'borderColor') { - const solidColor = typeof newColor === 'string' ? newColor : '#FFFFFF'; + const raw = typeof newColor === 'string' ? newColor : undefined; setBatchLocalColors((prev) => ({ ...prev, - borderColor: solidColor, + borderColor: toRgbHexColor(raw), + borderOpacity: parseAlphaPercent(raw, prev.borderOpacity), })); } else if (batchPickerFor === 'fill') { const solidColor = typeof newColor === 'string' ? newColor : '#FFFFFF'; @@ -2145,14 +2163,16 @@ const PropertiesPanel: React.FC = ({ handleBatchGlowColorChange(newColor); } } else if (batchPickerFor === 'borderColor') { - // noteBorderColor는 #RRGGBB 계약 — 피커 출력을 hex로 정규화 후 라이브 preview - const solidColor = toRgbHexColor( - typeof newColor === 'string' ? newColor : undefined, - ); + // noteBorderColor는 #RRGGBB 계약 — 색은 hex로 정규화, 알파는 noteBorderOpacity로 분리 + const raw = typeof newColor === 'string' ? newColor : undefined; + const solidColor = toRgbHexColor(raw); + const opacity = parseAlphaPercent(raw, batchLocalColors.borderOpacity); if (selectedKeyElements.length > 0 && selectedStatElements.length > 0) { handleBatchKeyOnlyStyleChange('noteBorderColor', solidColor); + handleBatchKeyOnlyStyleChange('noteBorderOpacity', opacity); } else { handleBatchStyleChange('noteBorderColor', solidColor); + handleBatchStyleChange('noteBorderOpacity', opacity); } } }; @@ -2166,10 +2186,11 @@ const PropertiesPanel: React.FC = ({ [batchPickerFor]: newColor, })); } else if (batchPickerFor === 'borderColor') { - const solidColor = typeof newColor === 'string' ? newColor : '#FFFFFF'; + const raw = typeof newColor === 'string' ? newColor : undefined; setBatchLocalColors((prev) => ({ ...prev, - borderColor: solidColor, + borderColor: toRgbHexColor(raw), + borderOpacity: parseAlphaPercent(raw, prev.borderOpacity), })); } else if (batchPickerFor === 'fill') { const solidColor = typeof newColor === 'string' ? newColor : '#FFFFFF'; @@ -2207,14 +2228,16 @@ const PropertiesPanel: React.FC = ({ handleBatchGlowColorChangeComplete(newColor); } } else if (batchPickerFor === 'borderColor') { - // noteBorderColor는 #RRGGBB 계약 — 피커의 rgba(...) 출력을 hex로 정규화 (이슈 #73) - const solidColor = toRgbHexColor( - typeof newColor === 'string' ? newColor : undefined, - ); + // noteBorderColor는 #RRGGBB 계약 — 색은 hex로 정규화(이슈 #73), 알파는 noteBorderOpacity로 분리 + const raw = typeof newColor === 'string' ? newColor : undefined; + const solidColor = toRgbHexColor(raw); + const opacity = parseAlphaPercent(raw, batchLocalColors.borderOpacity); if (selectedKeyElements.length > 0 && selectedStatElements.length > 0) { handleBatchKeyOnlyStyleChangeComplete('noteBorderColor', solidColor); + handleBatchKeyOnlyStyleChangeComplete('noteBorderOpacity', opacity); } else { handleBatchStyleChangeComplete('noteBorderColor', solidColor); + handleBatchStyleChangeComplete('noteBorderOpacity', opacity); } } else if (batchPickerFor === 'fill') { const fillColor = typeof newColor === 'string' ? newColor : '#FFFFFF'; diff --git a/src/renderer/components/main/Grid/PropertiesPanel/batch/BatchNoteTabContent.tsx b/src/renderer/components/main/Grid/PropertiesPanel/batch/BatchNoteTabContent.tsx index c686660b..fbf5bedd 100644 --- a/src/renderer/components/main/Grid/PropertiesPanel/batch/BatchNoteTabContent.tsx +++ b/src/renderer/components/main/Grid/PropertiesPanel/batch/BatchNoteTabContent.tsx @@ -120,9 +120,7 @@ const BatchNoteTabContent: React.FC = ({ {/* 오프셋 */} pos.noteOffsetX, 0).value || undefined - } + value={getMixedValue((pos) => pos.noteOffsetX, 0).value || undefined} onChange={(value) => handleBatchStyleChangeComplete('noteOffsetX', value) } @@ -134,9 +132,7 @@ const BatchNoteTabContent: React.FC = ({ isMixed={getMixedValue((pos) => pos.noteOffsetX, 0).isMixed} /> pos.noteOffsetY, 0).value || undefined - } + value={getMixedValue((pos) => pos.noteOffsetY, 0).value || undefined} onChange={(value) => handleBatchStyleChangeComplete('noteOffsetY', value) } diff --git a/src/renderer/components/main/Grid/PropertiesPanel/batch/BatchSelectionPanel.tsx b/src/renderer/components/main/Grid/PropertiesPanel/batch/BatchSelectionPanel.tsx index 8743eb63..636524a6 100644 --- a/src/renderer/components/main/Grid/PropertiesPanel/batch/BatchSelectionPanel.tsx +++ b/src/renderer/components/main/Grid/PropertiesPanel/batch/BatchSelectionPanel.tsx @@ -61,7 +61,13 @@ const RenameIcon: React.FC = () => ( // Mixed key-like + graph batch selection panel // ============================================================================ -type BatchPickerTarget = 'noteColor' | 'glowColor' | 'borderColor' | 'fill' | 'stroke' | null; +type BatchPickerTarget = + | 'noteColor' + | 'glowColor' + | 'borderColor' + | 'fill' + | 'stroke' + | null; type MixedValueResult = { isMixed: boolean; value: T }; type MixedValueGetter

= ( @@ -80,6 +86,7 @@ interface BatchLocalColors { noteColor: NoteColor; glowColor: NoteColor; borderColor: string; + borderOpacity: number; fillIdle: string; fillActive: string; strokeIdle: string; @@ -409,10 +416,7 @@ export const BatchKeyLikePanel: React.FC = ({ const mixedFn = selectedKeyElements.length > 0 ? getMixedValueKeysOnly : getMixedValue; - const { isMixed, value } = mixedFn( - (pos) => pos.noteBorderColor, - '#FFFFFF', - ); + const { isMixed, value } = mixedFn((pos) => pos.noteBorderColor, '#FFFFFF'); if (isMixed) return { style: { backgroundColor: '#666' }, diff --git a/src/renderer/components/main/Grid/PropertiesPanel/single/NoteTabContent.tsx b/src/renderer/components/main/Grid/PropertiesPanel/single/NoteTabContent.tsx index 161e50e1..f598136a 100644 --- a/src/renderer/components/main/Grid/PropertiesPanel/single/NoteTabContent.tsx +++ b/src/renderer/components/main/Grid/PropertiesPanel/single/NoteTabContent.tsx @@ -11,7 +11,12 @@ import { import Checkbox from '@components/main/common/Checkbox'; import Dropdown from '@components/main/common/Dropdown'; import ColorPicker from '@components/main/Modal/content/pickers/ColorPicker'; -import { isGradientColor, toRgbHexColor } from '@utils/color/colorUtils'; +import { + isGradientColor, + toRgbHexColor, + parseAlphaPercent, + hexWithAlphaPercent, +} from '@utils/color/colorUtils'; import { NOTE_SETTINGS_CONSTRAINTS } from '@src/types/settings/noteSettingsConstraints'; import { useSettingsStore } from '@stores/useSettingsStore'; @@ -258,11 +263,16 @@ const NoteTabContent: React.FC = ({ const [borderColor, setBorderColor] = useState( () => keyPosition.noteBorderColor ?? '#FFFFFF', ); + // 테두리 투명도(0~100). 노트 배경 투명도와 독립 + const [localBorderOpacity, setLocalBorderOpacity] = useState( + () => keyPosition.noteBorderOpacity ?? 100, + ); useEffect(() => { if (pickerFor === 'border') return; setBorderColor(keyPosition.noteBorderColor ?? '#FFFFFF'); - }, [keyPosition.noteBorderColor, pickerFor]); + setLocalBorderOpacity(keyPosition.noteBorderOpacity ?? 100); + }, [keyPosition.noteBorderColor, keyPosition.noteBorderOpacity, pickerFor]); const interactiveRefs = [ noteColorButtonRef, @@ -721,22 +731,35 @@ const NoteTabContent: React.FC = ({ ? notePickerColor : pickerFor === 'glow' ? glowPickerColor - : borderColor + : hexWithAlphaPercent(borderColor, localBorderOpacity) } onColorChange={(c: NoteColor) => { if (pickerFor === 'border') { - const hex = toRgbHexColor(typeof c === 'string' ? c : undefined); + const raw = typeof c === 'string' ? c : undefined; + const hex = toRgbHexColor(raw); + const opacity = parseAlphaPercent(raw, localBorderOpacity); setBorderColor(hex); + setLocalBorderOpacity(opacity); return; } handleColorChange(pickerFor, c); }} onColorChangeComplete={(c: NoteColor) => { if (pickerFor === 'border') { - const hex = toRgbHexColor(typeof c === 'string' ? c : undefined); + const raw = typeof c === 'string' ? c : undefined; + const hex = toRgbHexColor(raw); + const opacity = parseAlphaPercent(raw, localBorderOpacity); setBorderColor(hex); - onKeyPreview?.(keyIndex, { noteBorderColor: hex }); - onKeyUpdate({ index: keyIndex, noteBorderColor: hex }); + setLocalBorderOpacity(opacity); + onKeyPreview?.(keyIndex, { + noteBorderColor: hex, + noteBorderOpacity: opacity, + }); + onKeyUpdate({ + index: keyIndex, + noteBorderColor: hex, + noteBorderOpacity: opacity, + }); return; } handleColorChangeComplete(pickerFor, c); diff --git a/src/renderer/components/overlay/WebGLTracksOGL.tsx b/src/renderer/components/overlay/WebGLTracksOGL.tsx index af3d245e..2877aba9 100644 --- a/src/renderer/components/overlay/WebGLTracksOGL.tsx +++ b/src/renderer/components/overlay/WebGLTracksOGL.tsx @@ -18,6 +18,7 @@ const vertexShader = ` attribute vec3 noteGlowColorTop; attribute vec3 noteGlowColorBottom; attribute vec4 noteBorder; // x: width, yzw: RGB color + attribute float noteBorderOpacity; // 0-1, 노트 배경 투명도와 독립 attribute float trackIndex; uniform mat4 projectionMatrix; @@ -38,6 +39,7 @@ const vertexShader = ` varying vec3 vGlowColorTop; varying vec3 vGlowColorBottom; varying vec4 vBorder; // x: width, yzw: RGB color + varying float vBorderOpacity; varying float vTrackTopY; varying float vTrackBottomY; @@ -130,6 +132,7 @@ const vertexShader = ` vGlowColorTop = noteGlowColorTop; vGlowColorBottom = noteGlowColorBottom; vBorder = noteBorder; + vBorderOpacity = noteBorderOpacity; vTrackTopY = trackTopY; vTrackBottomY = trackBottomY; } @@ -153,6 +156,7 @@ const fragmentShader = ` varying vec3 vGlowColorTop; varying vec3 vGlowColorBottom; varying vec4 vBorder; // x: width, yzw: RGB color + varying float vBorderOpacity; varying float vTrackTopY; varying float vTrackBottomY; @@ -212,7 +216,8 @@ const fragmentShader = ` } float borderMask = outerMask - innerMask; float bodyAlpha = baseColor.a * innerMask; - float borderAlpha = baseColor.a * borderMask; + // 테두리 투명도는 노트 배경(baseColor.a)과 독립 + float borderAlpha = vBorderOpacity * borderMask; float glowAlpha = 0.0; if (vGlowSize > 0.0) { @@ -263,6 +268,7 @@ const INSTANCED_ATTRIBUTE_KEYS: readonly string[] = Object.freeze([ 'noteGlowColorTop', 'noteGlowColorBottom', 'noteBorder', + 'noteBorderOpacity', 'trackIndex', ]); @@ -372,6 +378,7 @@ interface NoteBuffer { noteGlowColorTop: Float32Array; noteGlowColorBottom: Float32Array; noteBorder: Float32Array; + noteBorderOpacity: Float32Array; trackIndex: Float32Array; } @@ -515,6 +522,12 @@ export function WebGLTracksOGL({ data: noteBuffer.noteBorder, usage: gl.DYNAMIC_DRAW, }); + geometry.addAttribute('noteBorderOpacity', { + instanced: 1, + size: 1, + data: noteBuffer.noteBorderOpacity, + usage: gl.DYNAMIC_DRAW, + }); geometry.addAttribute('trackIndex', { instanced: 1, size: 1, diff --git a/src/renderer/hooks/shared/useLayoutComputation.ts b/src/renderer/hooks/shared/useLayoutComputation.ts index b9371a73..94c48530 100644 --- a/src/renderer/hooks/shared/useLayoutComputation.ts +++ b/src/renderer/hooks/shared/useLayoutComputation.ts @@ -238,6 +238,7 @@ export function computeLayout(input: LayoutInput) { borderRadius: position.noteBorderRadius ?? DEFAULT_NOTE_BORDER_RADIUS, noteBorderWidth: position.noteBorderWidth ?? 0, noteBorderColor: position.noteBorderColor, + noteBorderOpacity: position.noteBorderOpacity ?? 100, noteBorderSide: position.noteBorderSide ?? 'all', }; }) diff --git a/src/renderer/stores/signals/noteBuffer.ts b/src/renderer/stores/signals/noteBuffer.ts index a76254e9..bcd14a39 100644 --- a/src/renderer/stores/signals/noteBuffer.ts +++ b/src/renderer/stores/signals/noteBuffer.ts @@ -79,6 +79,7 @@ export type TrackLayoutInput = { borderRadius?: number; noteBorderWidth?: number; noteBorderColor?: string; + noteBorderOpacity?: number; noteBorderSide?: 'all' | 'vertical' | 'horizontal'; }; @@ -95,6 +96,7 @@ type ResolvedTrackStyle = { borderRadius: number; borderWidth: number; borderColor: readonly number[]; + borderOpacity: number; }; type ResolvedTrackLayout = TrackLayoutInput & { @@ -152,6 +154,11 @@ const resolveTrackLayout = (layout: TrackLayoutInput): ResolvedTrackLayout => { const borderWidth = rawBorderWidth + sideOffset; const borderColorParsed = parseColor(layout.noteBorderColor ?? '#FFFFFF'); const borderColorSRGB = convertLinearToSRGB(borderColorParsed); + const borderOpacityPercent = + layout.noteBorderOpacity != null && + Number.isFinite(layout.noteBorderOpacity) + ? layout.noteBorderOpacity + : 100; return { ...layout, @@ -172,6 +179,7 @@ const resolveTrackLayout = (layout: TrackLayoutInput): ResolvedTrackLayout => { borderRadius: layout.borderRadius ?? DEFAULT_NOTE_BORDER_RADIUS, borderWidth, borderColor: borderColorSRGB, + borderOpacity: clampPercentToUnit(borderOpacityPercent), }, }; }; @@ -184,6 +192,7 @@ export class NoteBuffer { readonly noteColorTop: Float32Array; readonly noteColorBottom: Float32Array; readonly noteRadius: Float32Array; + readonly noteBorderOpacity: Float32Array; readonly trackIndex: Float32Array; readonly noteGlow: Float32Array; readonly noteGlowColorTop: Float32Array; @@ -206,6 +215,7 @@ export class NoteBuffer { this.noteColorTop = new Float32Array(MAX_NOTES * 4); this.noteColorBottom = new Float32Array(MAX_NOTES * 4); this.noteRadius = new Float32Array(MAX_NOTES); + this.noteBorderOpacity = new Float32Array(MAX_NOTES); this.trackIndex = new Float32Array(MAX_NOTES); this.noteGlow = new Float32Array(MAX_NOTES * 3); this.noteGlowColorTop = new Float32Array(MAX_NOTES * 3); @@ -273,6 +283,7 @@ export class NoteBuffer { borderRadius, borderWidth, borderColor, + borderOpacity, } = layout.resolved; const trackIndex = layout.trackIndex; @@ -311,6 +322,11 @@ export class NoteBuffer { insertIndex, this.activeCount, ); + this.noteBorderOpacity.copyWithin( + insertIndex + 1, + insertIndex, + this.activeCount, + ); this.trackIndex.copyWithin( insertIndex + 1, insertIndex, @@ -371,6 +387,7 @@ export class NoteBuffer { this.noteColorBottom[colorOffset + 3] = opacityBottom; this.noteRadius[insertIndex] = borderRadius; + this.noteBorderOpacity[insertIndex] = borderOpacity; this.trackIndex[insertIndex] = trackIndex; const glowOffset = insertIndex * 3; this.noteGlow[glowOffset] = glowSize; @@ -431,6 +448,7 @@ export class NoteBuffer { this.noteColorTop.copyWithin(index * 4, nextIndex * 4, totalColor); this.noteColorBottom.copyWithin(index * 4, nextIndex * 4, totalColor); this.noteRadius.copyWithin(index, nextIndex, last + 1); + this.noteBorderOpacity.copyWithin(index, nextIndex, last + 1); this.trackIndex.copyWithin(index, nextIndex, last + 1); this.noteGlow.copyWithin(index * 3, nextIndex * 3, (last + 1) * 3); this.noteGlowColorTop.copyWithin( @@ -471,6 +489,7 @@ export class NoteBuffer { this.noteColorTop.fill(0, colorOffset, colorOffset + 4); this.noteColorBottom.fill(0, colorOffset, colorOffset + 4); this.noteRadius[last] = 0; + this.noteBorderOpacity[last] = 0; this.trackIndex[last] = 0; const glowOffset = last * 3; this.noteGlow.fill(0, glowOffset, glowOffset + 3); @@ -534,6 +553,7 @@ export class NoteBuffer { this.noteColorTop.fill(0, writeIndex * 4, previousCount * 4); this.noteColorBottom.fill(0, writeIndex * 4, previousCount * 4); this.noteRadius.fill(0, writeIndex, previousCount); + this.noteBorderOpacity.fill(0, writeIndex, previousCount); this.trackIndex.fill(0, writeIndex, previousCount); this.noteGlow.fill(0, writeIndex * 3, previousCount * 3); this.noteGlowColorTop.fill(0, writeIndex * 3, previousCount * 3); @@ -557,6 +577,7 @@ export class NoteBuffer { this.noteColorTop.fill(0); this.noteColorBottom.fill(0); this.noteRadius.fill(0); + this.noteBorderOpacity.fill(0); this.trackIndex.fill(0); this.noteGlow.fill(0); this.noteGlowColorTop.fill(0); @@ -589,6 +610,7 @@ export class NoteBuffer { this.noteColorBottom[toColor + 3] = this.noteColorBottom[fromColor + 3]; this.noteRadius[to] = this.noteRadius[from]; + this.noteBorderOpacity[to] = this.noteBorderOpacity[from]; this.trackIndex[to] = this.trackIndex[from]; const fromGlow = from * 3; diff --git a/src/renderer/utils/color/colorUtils.test.ts b/src/renderer/utils/color/colorUtils.test.ts index 4b43abcb..ede3fd38 100644 --- a/src/renderer/utils/color/colorUtils.test.ts +++ b/src/renderer/utils/color/colorUtils.test.ts @@ -7,8 +7,41 @@ import { toColorObject, toCssRgba, toRgbHexColor, + parseAlphaPercent, + hexWithAlphaPercent, } from './colorUtils'; +describe('parseAlphaPercent', () => { + it('rgba 문자열의 알파를 퍼센트로 추출', () => { + expect(parseAlphaPercent('rgba(255, 0, 0, 0.5)')).toBe(50); + expect(parseAlphaPercent('rgba(0, 0, 0, 1)')).toBe(100); + expect(parseAlphaPercent('rgba(0, 0, 0, 0)')).toBe(0); + }); + + it('8자리 hex의 알파를 추출', () => { + expect(parseAlphaPercent('#FF000080')).toBe(50); + expect(parseAlphaPercent('#FF0000FF')).toBe(100); + }); + + it('알파 없는 입력은 fallback 반환', () => { + expect(parseAlphaPercent('#FF0000', 70)).toBe(70); + expect(parseAlphaPercent(undefined, 100)).toBe(100); + }); +}); + +describe('hexWithAlphaPercent', () => { + it('hex + 퍼센트 → rgba', () => { + expect(hexWithAlphaPercent('#FF0000', 50)).toBe('rgba(255, 0, 0, 0.5)'); + expect(hexWithAlphaPercent('#00FF00', 100)).toBe('rgba(0, 255, 0, 1)'); + }); + + it('parseAlphaPercent와 왕복 일관성', () => { + const css = hexWithAlphaPercent('#123456', 40); + expect(parseAlphaPercent(css)).toBe(40); + expect(toRgbHexColor(css)).toBe('#123456'); + }); +}); + describe('isGradientColor', () => { it('gradient 객체를 감지', () => { expect( diff --git a/src/renderer/utils/color/colorUtils.ts b/src/renderer/utils/color/colorUtils.ts index 89274f35..29461a78 100644 --- a/src/renderer/utils/color/colorUtils.ts +++ b/src/renderer/utils/color/colorUtils.ts @@ -192,6 +192,28 @@ const parseRgbaString = ( return { r, g, b, a }; }; +// rgba(...)/8자리 hex에서 알파를 0~100 정수 퍼센트로 추출. 없으면 fallback +const parseAlphaPercent = ( + value: string | null | undefined, + fallback = 100, +): number => { + if (typeof value !== 'string') return fallback; + const trimmed = value.trim(); + const rgba = parseRgbaString(trimmed); + if (rgba) return Math.round(rgba.a * 100); + const hex8 = trimmed.match(/^#([0-9a-f]{6})([0-9a-f]{2})$/i); + if (hex8) return Math.round((parseInt(hex8[2], 16) / 255) * 100); + return fallback; +}; + +// #RRGGBB + 퍼센트 알파 → rgba(...) 문자열 (ColorPicker solidOnly 입력용) +const hexWithAlphaPercent = (hex: string, percent: number): string => { + const parsed = parseHexColor(hex); + const a = clamp(percent / 100, 0, 1); + if (!parsed) return `rgba(255, 255, 255, ${a})`; + return `rgba(${parsed.rgb.r}, ${parsed.rgb.g}, ${parsed.rgb.b}, ${a})`; +}; + const toColorObject = ( value: string | Partial | null | undefined, ): ColorObject | null => { @@ -297,4 +319,6 @@ export { rgbToHsv, toColorObject, toCssRgba, + parseAlphaPercent, + hexWithAlphaPercent, }; diff --git a/src/types/key/keys.ts b/src/types/key/keys.ts index e2d2298a..bc53b832 100644 --- a/src/types/key/keys.ts +++ b/src/types/key/keys.ts @@ -292,6 +292,8 @@ export const keyPositionSchema = z.object({ .string() .regex(/^#[0-9A-Fa-f]{6}$/) .optional(), + // 테두리 투명도 (0~100, 없으면 100). 노트 배경 투명도와 독립 + noteBorderOpacity: z.number().int().min(0).max(100).optional(), noteBorderSide: z.enum(['all', 'vertical', 'horizontal']).optional(), className: z.string().optional().or(z.literal('')), zIndex: z.number().optional(), From 4d8739ff35e13087eed6148a45ef8ff3311dbc0d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=97=B0=EC=9A=B0?= Date: Sun, 21 Jun 2026 22:39:30 +0900 Subject: [PATCH 3/6] =?UTF-8?q?fix:=20=EB=85=B8=ED=8A=B8=20=EC=8B=9C?= =?UTF-8?q?=EA=B0=84=20=ED=95=B4=EC=83=81=EB=8F=84=EA=B0=80=20=EB=AA=A8?= =?UTF-8?q?=EB=8B=88=ED=84=B0=20=EC=A3=BC=EC=82=AC=EC=9C=A8=C2=B7OBS=20fps?= =?UTF-8?q?=EC=97=90=20=EC=A2=85=EC=86=8D=EB=90=98=EB=8D=98=20=EB=AC=B8?= =?UTF-8?q?=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 키 입력이 requestAnimationFrame 안에서 처리돼 노트 생성 시각이 프레임 경계로 양자화되면서, 프레임 제한을 0으로 둬도 노트 표시 위치의 시간 해상도가 주사율(OBS는 OBS fps)에 종속되던 문제 수정 - 백엔드 keys:state payload에 eventAgeMs(입력 수신~emit 경과 ms) 추가 - 오버레이가 performance.now() - eventAgeMs로 실제 입력 시각을 복원해 노트 startTime에 반영. RAF 래핑 제거로 프레임 양자화 제거 - 절대 시각 공유 대신 상대 age를 전달해 Rust/JS·OBS 시간축 차이 회피 - 딜레이 노트 타이머는 max(0, ...)로 clamp - 공개 플러그인 API(onKeyState) 문서 en/ko 동기화 --- docs/content/en/api-reference/keys/page.mdx | 3 +++ docs/content/ko/api-reference/keys/page.mdx | 3 +++ src-tauri/src/state/app_state.rs | 9 ++++++++- src/renderer/hooks/overlay/useNoteSystem.ts | 22 +++++++++++---------- src/renderer/utils/core/keyEventBus.ts | 1 + src/renderer/windows/overlay/App.tsx | 12 ++++++----- src/types/plugin/api.ts | 8 +++++++- 7 files changed, 41 insertions(+), 17 deletions(-) diff --git a/docs/content/en/api-reference/keys/page.mdx b/docs/content/en/api-reference/keys/page.mdx index 1c4ae695..3ae36120 100644 --- a/docs/content/en/api-reference/keys/page.mdx +++ b/docs/content/en/api-reference/keys/page.mdx @@ -20,6 +20,9 @@ interface KeyStateEvent { key: string; // e.g., 'KeyA', 'MouseLeft' state: "UP" | "DOWN"; mode: "keyboard" | "mouse"; + // Elapsed time (ms) between input capture and event emit. + // Recover the real input time with `performance.now() - eventAgeMs`. + eventAgeMs?: number; } const unsub = dmn.keys.onKeyState(({ key, state, mode }) => { diff --git a/docs/content/ko/api-reference/keys/page.mdx b/docs/content/ko/api-reference/keys/page.mdx index f20c97db..d1da8518 100644 --- a/docs/content/ko/api-reference/keys/page.mdx +++ b/docs/content/ko/api-reference/keys/page.mdx @@ -160,6 +160,9 @@ interface KeyStatePayload { key: string; // 키 코드 (예: "KeyD") state: string; // "DOWN" | "UP" mode: string; // 현재 모드 + // 입력 수신~emit 경과 시간(ms). + // `performance.now() - eventAgeMs`로 실제 입력 시각 복원 + eventAgeMs?: number; } ``` diff --git a/src-tauri/src/state/app_state.rs b/src-tauri/src/state/app_state.rs index 31a36766..8243604d 100644 --- a/src-tauri/src/state/app_state.rs +++ b/src-tauri/src/state/app_state.rs @@ -849,6 +849,9 @@ impl AppState { continue; } + // 입력 수신 시각 — 노트 위치의 프레임 양자화 보정용 age 측정 기준 + let recv_at = Instant::now(); + // 우선 형식: JSON 인코딩된 HookMessage (device 포함) let parsed: Option = serde_json::from_str(s).ok(); @@ -1022,7 +1025,11 @@ impl AppState { } } } - let payload = json!({ "key": key_label, "state": state, "mode": mode }); + // 입력 수신~emit 사이 경과 시간(ms). 오버레이가 + // performance.now() - eventAgeMs로 실제 입력 시각을 복원해 + // 노트 시작 위치가 렌더 프레임 경계에 양자화되는 것을 방지 + let event_age_ms = recv_at.elapsed().as_secs_f64() * 1000.0; + let payload = json!({ "key": key_label, "state": state, "mode": mode, "eventAgeMs": event_age_ms }); let mut emitted = false; if let Some(overlay) = overlay_window.as_ref() { diff --git a/src/renderer/hooks/overlay/useNoteSystem.ts b/src/renderer/hooks/overlay/useNoteSystem.ts index 803234ef..a9519a8a 100644 --- a/src/renderer/hooks/overlay/useNoteSystem.ts +++ b/src/renderer/hooks/overlay/useNoteSystem.ts @@ -55,8 +55,8 @@ interface UseNoteSystemOptions { interface UseNoteSystemReturn { notesRef: React.MutableRefObject>; subscribe: (callback: NoteSubscriber) => () => void; - handleKeyDown: (keyName: string) => void; - handleKeyUp: (keyName: string) => void; + handleKeyDown: (keyName: string, eventTime?: number) => void; + handleKeyUp: (keyName: string, eventTime?: number) => void; finalizeAllActive: () => void; noteBuffer: NoteBuffer; updateTrackLayouts: (layouts: TrackLayoutInput[]) => void; @@ -467,8 +467,8 @@ export function useNoteSystem({ finalizeTimersRef.current.set(state.noteId, timer); }; - // 노트 생성/완료 - const handleKeyDown = (keyName: string): void => { + // 노트 생성/완료. eventTime: 실제 입력 시각(performance.now 기준 보정값) + const handleKeyDown = (keyName: string, eventTime?: number): void => { if (!noteEffectEnabled.current) return; const useDelay = delayEnabledRef.current && delayMsRef.current > 0; @@ -484,7 +484,7 @@ export function useNoteSystem({ if (useDelay) { const delayMs = delayMsRef.current; - const downTime = performance.now(); + const downTime = eventTime ?? performance.now(); const state: NoteState = { useDelay: true, downTime, @@ -517,14 +517,16 @@ export function useNoteSystem({ scheduleNoteFinalization(keyName, state, { forceMinLength }); state.releasedBeforeStart = false; } - }, delayMs); + // 실제 입력 시각 기준으로 노트 등장 시점을 맞춤. 입력 시각이 과거면 + // 남은 대기를 0으로 clamp해 타이머가 음수가 되지 않도록 함 + }, Math.max(0, downTime + delayMs - performance.now())); state.startTimer = startTimer; stateList.push(state); return; } - const noteId = createNote(keyName); + const noteId = createNote(keyName, eventTime); const createdNote = noteLookupRef.current.get(noteId); const noteStartTime = createdNote?.startTime ?? performance.now(); stateList.push({ @@ -539,7 +541,7 @@ export function useNoteSystem({ }); }; - const handleKeyUp = (keyName: string): void => { + const handleKeyUp = (keyName: string, eventTime?: number): void => { if (!noteEffectEnabled.current) return; const stateList = activeNotes.current.get(keyName); @@ -555,13 +557,13 @@ export function useNoteSystem({ if (!state) return; - const now = performance.now(); + const now = eventTime ?? performance.now(); state.released = true; state.releaseTime = now; if (!state.useDelay) { if (state.created && state.noteId) { - finalizeNote(keyName, state.noteId); + finalizeNote(keyName, state.noteId, now); } removeState(keyName, state); return; diff --git a/src/renderer/utils/core/keyEventBus.ts b/src/renderer/utils/core/keyEventBus.ts index a52de7d7..54c1772e 100644 --- a/src/renderer/utils/core/keyEventBus.ts +++ b/src/renderer/utils/core/keyEventBus.ts @@ -7,6 +7,7 @@ type KeyStatePayload = { key: string; state: string; mode: string; + eventAgeMs?: number; }; type KeyEventListener = (payload: KeyStatePayload) => void; diff --git a/src/renderer/windows/overlay/App.tsx b/src/renderer/windows/overlay/App.tsx index c5db2ce0..f575dd92 100644 --- a/src/renderer/windows/overlay/App.tsx +++ b/src/renderer/windows/overlay/App.tsx @@ -438,7 +438,7 @@ export default function App() { // 버스를 통해 키 이벤트 수신 const unsubscribe = import('@utils/core/keyEventBus').then( ({ keyEventBus }) => { - return keyEventBus.subscribe(({ key, state }) => { + return keyEventBus.subscribe(({ key, state, eventAgeMs }) => { const isDown = state === 'DOWN'; // 키 UI 업데이트 (딜레이 적용) updateKeySignalWithDelay(key, isDown); @@ -453,10 +453,12 @@ export default function App() { keyPosition?.noteEffectEnabled !== false; if (keyNoteEffectEnabled) { - requestAnimationFrame(() => { - if (isDown) handleKeyDown(key); - else handleKeyUp(key); - }); + // 실제 입력 시각을 복원해 노트 시작 위치를 보정 (프레임 양자화 방지). + // requestAnimationFrame 래핑 시 노트 생성 시각이 프레임 경계로 양자화돼 + // 주사율/OBS fps에 시간 해상도가 종속되던 문제 해결 + const inputTime = performance.now() - (eventAgeMs ?? 0); + if (isDown) handleKeyDown(key, inputTime); + else handleKeyUp(key, inputTime); } } }); diff --git a/src/types/plugin/api.ts b/src/types/plugin/api.ts index 86f2e4f2..072a44b1 100644 --- a/src/types/plugin/api.ts +++ b/src/types/plugin/api.ts @@ -25,7 +25,13 @@ export type CustomTabsChangePayload = { customTabs: CustomTab[]; selectedKeyType: string; }; -export type KeyStatePayload = { key: string; state: string; mode: string }; +export type KeyStatePayload = { + key: string; + state: string; + mode: string; + /** 입력 수신~emit 경과 시간(ms). performance.now() - eventAgeMs로 실제 입력 시각 복원 */ + eventAgeMs?: number; +}; export type InputDevice = 'keyboard' | 'mouse' | 'gamepad' | 'unknown'; export type RawInputPayload = { device: InputDevice; From 10129569d33d1b18f5c25685f23445d3546c9035 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=97=B0=EC=9A=B0?= Date: Mon, 22 Jun 2026 15:20:41 +0900 Subject: [PATCH 4/6] =?UTF-8?q?fix:=20eventAgeMs=20=EB=B3=B4=EC=A0=95?= =?UTF-8?q?=EA=B0=92=20=EC=83=81=ED=95=9C=20=ED=81=B4=EB=9E=A8=ED=94=84=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OBS browser source 타이밍 리서치 반영. 백엔드 stall이나 클럭 이상으로 eventAgeMs가 비정상적으로 커질 경우 노트가 화면 위로 튀는 것을 방지하기 위해 0~250ms로 clamp --- .../stores/signals/noteBuffer.test.ts | 85 +++++++++++++++++++ src/renderer/windows/overlay/App.tsx | 14 ++- 2 files changed, 97 insertions(+), 2 deletions(-) create mode 100644 src/renderer/stores/signals/noteBuffer.test.ts diff --git a/src/renderer/stores/signals/noteBuffer.test.ts b/src/renderer/stores/signals/noteBuffer.test.ts new file mode 100644 index 00000000..308b355c --- /dev/null +++ b/src/renderer/stores/signals/noteBuffer.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, it } from 'vitest'; +import { createNoteBuffer } from './noteBuffer'; + +describe('NoteBuffer', () => { + it('keeps note border opacity independent from note body opacity', () => { + const buffer = createNoteBuffer(); + + buffer.updateTrackLayouts([ + { + trackKey: 'Z', + trackIndex: 0, + position: { dx: 10, dy: 20 }, + width: 60, + height: 60, + noteColor: '#24BBB4', + noteOpacity: 0, + noteOpacityTop: 0, + noteOpacityBottom: 0, + noteBorderColor: '#FFFFFF', + noteBorderOpacity: 100, + noteBorderWidth: 12, + }, + ]); + + const index = buffer.allocate('Z', 'note-z', 1234.5); + + expect(index).toBe(0); + expect(buffer.noteColorTop[3]).toBe(0); + expect(buffer.noteColorBottom[3]).toBe(0); + expect(buffer.noteBorderOpacity[0]).toBe(1); + expect(buffer.noteBorder[0]).toBe(12); + }); + + it('stores semi-transparent border alpha separately from solid note alpha', () => { + const buffer = createNoteBuffer(); + + buffer.updateTrackLayouts([ + { + trackKey: 'X', + trackIndex: 0, + position: { dx: 10, dy: 20 }, + width: 60, + height: 60, + noteColor: '#FFFFFF', + noteOpacity: 100, + noteBorderColor: '#FF0000', + noteBorderOpacity: 50, + noteBorderWidth: 8, + }, + ]); + + buffer.allocate('X', 'note-x', 2000); + + expect(buffer.noteColorTop[3]).toBe(1); + expect(buffer.noteColorBottom[3]).toBe(1); + expect(buffer.noteBorderOpacity[0]).toBe(0.5); + expect(buffer.noteBorder[0]).toBe(8); + expect(buffer.noteBorder[1]).toBeCloseTo(1, 5); + expect(buffer.noteBorder[2]).toBeCloseTo(0, 5); + expect(buffer.noteBorder[3]).toBeCloseTo(0, 5); + }); + + it('preserves sub-frame note start times without quantizing to frame steps', () => { + const buffer = createNoteBuffer(); + + buffer.updateTrackLayouts([ + { + trackKey: 'Z', + trackIndex: 0, + position: { dx: 10, dy: 20 }, + width: 60, + height: 60, + noteColor: '#FFFFFF', + noteOpacity: 80, + }, + ]); + + buffer.allocate('Z', 'note-1', 1000.25); + buffer.allocate('Z', 'note-2', 1004.75); + + expect(buffer.noteInfo[0]).toBeCloseTo(1000.25, 3); + expect(buffer.noteInfo[3]).toBeCloseTo(1004.75, 3); + expect(buffer.noteInfo[3] - buffer.noteInfo[0]).toBeCloseTo(4.5, 3); + }); +}); diff --git a/src/renderer/windows/overlay/App.tsx b/src/renderer/windows/overlay/App.tsx index f575dd92..94f1ea83 100644 --- a/src/renderer/windows/overlay/App.tsx +++ b/src/renderer/windows/overlay/App.tsx @@ -32,6 +32,10 @@ import { computeLayout } from '@hooks/shared/useLayoutComputation'; type KeyDelayTimerEntry = { timers: Set> }; +// 입력 시각 보정용 age 상한(ms). 백엔드 stall/클럭 이상으로 비정상적으로 큰 +// 값이 와도 노트가 화면 위로 튀지 않도록 제한 +const MAX_EVENT_AGE_MS = 250; + export default function App() { useCustomCssInjection(); useCustomJsInjection(); @@ -455,8 +459,14 @@ export default function App() { if (keyNoteEffectEnabled) { // 실제 입력 시각을 복원해 노트 시작 위치를 보정 (프레임 양자화 방지). // requestAnimationFrame 래핑 시 노트 생성 시각이 프레임 경계로 양자화돼 - // 주사율/OBS fps에 시간 해상도가 종속되던 문제 해결 - const inputTime = performance.now() - (eventAgeMs ?? 0); + // 주사율/OBS fps에 시간 해상도가 종속되던 문제 해결. + // age는 0~MAX_EVENT_AGE_MS로 clamp — 백엔드 stall/클럭 이상 시 노트가 + // 화면 위로 튀는 것을 방지 + const age = Math.min( + Math.max(eventAgeMs ?? 0, 0), + MAX_EVENT_AGE_MS, + ); + const inputTime = performance.now() - age; if (isDown) handleKeyDown(key, inputTime); else handleKeyUp(key, inputTime); } From 2dfea5cbd2d3de72fe648d4e33b2c5765436a0ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=97=B0=EC=9A=B0?= Date: Mon, 22 Jun 2026 16:16:39 +0900 Subject: [PATCH 5/6] =?UTF-8?q?test:=20=EB=85=B8=ED=8A=B8=20=ED=85=8C?= =?UTF-8?q?=EB=91=90=EB=A6=AC=20=ED=88=AC=EB=AA=85=EB=8F=84=20=EB=B2=84?= =?UTF-8?q?=ED=8D=BC=20=EB=8F=99=EA=B8=B0=ED=99=94=20=ED=9A=8C=EA=B7=80=20?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EB=B3=B4=EA=B0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 코드 리뷰 반영. noteBorderOpacity가 삽입 시프트/단일 release/clear 경로에서 해당 노트에 올바르게 따라붙는지 검증하는 테스트 추가 --- .../stores/signals/noteBuffer.test.ts | 53 +++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/src/renderer/stores/signals/noteBuffer.test.ts b/src/renderer/stores/signals/noteBuffer.test.ts index 308b355c..563a9d30 100644 --- a/src/renderer/stores/signals/noteBuffer.test.ts +++ b/src/renderer/stores/signals/noteBuffer.test.ts @@ -60,6 +60,59 @@ describe('NoteBuffer', () => { expect(buffer.noteBorder[3]).toBeCloseTo(0, 5); }); + it('keeps border opacity attached to its note through insert shift / release / clear', () => { + const buffer = createNoteBuffer(); + + // 트랙 A(트랙인덱스 0, 테두리 30%), B(트랙인덱스 1, 테두리 80%) + buffer.updateTrackLayouts([ + { + trackKey: 'A', + trackIndex: 0, + position: { dx: 0, dy: 0 }, + width: 60, + height: 60, + noteColor: '#FFFFFF', + noteOpacity: 100, + noteBorderColor: '#FFFFFF', + noteBorderOpacity: 30, + noteBorderWidth: 4, + }, + { + trackKey: 'B', + trackIndex: 1, + position: { dx: 0, dy: 0 }, + width: 60, + height: 60, + noteColor: '#FFFFFF', + noteOpacity: 100, + noteBorderColor: '#FFFFFF', + noteBorderOpacity: 80, + noteBorderWidth: 4, + }, + ]); + + // B 먼저 할당 후 A 할당 — A는 트랙인덱스가 더 작아 앞으로 삽입되며 B를 시프트 + buffer.allocate('B', 'b1', 2000); + buffer.allocate('A', 'a1', 1000); + + // 시프트 후: index0=A(0.3), index1=B(0.8) — 시작시각으로 슬롯 식별 + expect(buffer.noteInfo[0]).toBeCloseTo(1000, 3); + expect(buffer.noteBorderOpacity[0]).toBeCloseTo(0.3, 5); + expect(buffer.noteInfo[3]).toBeCloseTo(2000, 3); + expect(buffer.noteBorderOpacity[1]).toBeCloseTo(0.8, 5); + + // A 제거 → B가 index0으로 시프트, 테두리 opacity도 따라옴 + buffer.release('a1'); + expect(buffer.activeCount).toBe(1); + expect(buffer.noteInfo[0]).toBeCloseTo(2000, 3); + expect(buffer.noteBorderOpacity[0]).toBeCloseTo(0.8, 5); + + // clear → 슬롯 초기화 + buffer.clear(); + expect(buffer.activeCount).toBe(0); + expect(buffer.noteBorderOpacity[0]).toBe(0); + }); + it('preserves sub-frame note start times without quantizing to frame steps', () => { const buffer = createNoteBuffer(); From cff147d46681186d34188106bafe0e17278ce6c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=97=B0=EC=9A=B0?= Date: Mon, 22 Jun 2026 17:00:57 +0900 Subject: [PATCH 6/6] =?UTF-8?q?test:=20=EC=B6=94=EA=B0=80=ED=96=88?= =?UTF-8?q?=EB=8D=98=20=EB=8B=A8=EC=9C=84=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../stores/signals/noteBuffer.test.ts | 138 ------------------ src/renderer/utils/color/colorUtils.test.ts | 33 ----- 2 files changed, 171 deletions(-) delete mode 100644 src/renderer/stores/signals/noteBuffer.test.ts diff --git a/src/renderer/stores/signals/noteBuffer.test.ts b/src/renderer/stores/signals/noteBuffer.test.ts deleted file mode 100644 index 563a9d30..00000000 --- a/src/renderer/stores/signals/noteBuffer.test.ts +++ /dev/null @@ -1,138 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { createNoteBuffer } from './noteBuffer'; - -describe('NoteBuffer', () => { - it('keeps note border opacity independent from note body opacity', () => { - const buffer = createNoteBuffer(); - - buffer.updateTrackLayouts([ - { - trackKey: 'Z', - trackIndex: 0, - position: { dx: 10, dy: 20 }, - width: 60, - height: 60, - noteColor: '#24BBB4', - noteOpacity: 0, - noteOpacityTop: 0, - noteOpacityBottom: 0, - noteBorderColor: '#FFFFFF', - noteBorderOpacity: 100, - noteBorderWidth: 12, - }, - ]); - - const index = buffer.allocate('Z', 'note-z', 1234.5); - - expect(index).toBe(0); - expect(buffer.noteColorTop[3]).toBe(0); - expect(buffer.noteColorBottom[3]).toBe(0); - expect(buffer.noteBorderOpacity[0]).toBe(1); - expect(buffer.noteBorder[0]).toBe(12); - }); - - it('stores semi-transparent border alpha separately from solid note alpha', () => { - const buffer = createNoteBuffer(); - - buffer.updateTrackLayouts([ - { - trackKey: 'X', - trackIndex: 0, - position: { dx: 10, dy: 20 }, - width: 60, - height: 60, - noteColor: '#FFFFFF', - noteOpacity: 100, - noteBorderColor: '#FF0000', - noteBorderOpacity: 50, - noteBorderWidth: 8, - }, - ]); - - buffer.allocate('X', 'note-x', 2000); - - expect(buffer.noteColorTop[3]).toBe(1); - expect(buffer.noteColorBottom[3]).toBe(1); - expect(buffer.noteBorderOpacity[0]).toBe(0.5); - expect(buffer.noteBorder[0]).toBe(8); - expect(buffer.noteBorder[1]).toBeCloseTo(1, 5); - expect(buffer.noteBorder[2]).toBeCloseTo(0, 5); - expect(buffer.noteBorder[3]).toBeCloseTo(0, 5); - }); - - it('keeps border opacity attached to its note through insert shift / release / clear', () => { - const buffer = createNoteBuffer(); - - // 트랙 A(트랙인덱스 0, 테두리 30%), B(트랙인덱스 1, 테두리 80%) - buffer.updateTrackLayouts([ - { - trackKey: 'A', - trackIndex: 0, - position: { dx: 0, dy: 0 }, - width: 60, - height: 60, - noteColor: '#FFFFFF', - noteOpacity: 100, - noteBorderColor: '#FFFFFF', - noteBorderOpacity: 30, - noteBorderWidth: 4, - }, - { - trackKey: 'B', - trackIndex: 1, - position: { dx: 0, dy: 0 }, - width: 60, - height: 60, - noteColor: '#FFFFFF', - noteOpacity: 100, - noteBorderColor: '#FFFFFF', - noteBorderOpacity: 80, - noteBorderWidth: 4, - }, - ]); - - // B 먼저 할당 후 A 할당 — A는 트랙인덱스가 더 작아 앞으로 삽입되며 B를 시프트 - buffer.allocate('B', 'b1', 2000); - buffer.allocate('A', 'a1', 1000); - - // 시프트 후: index0=A(0.3), index1=B(0.8) — 시작시각으로 슬롯 식별 - expect(buffer.noteInfo[0]).toBeCloseTo(1000, 3); - expect(buffer.noteBorderOpacity[0]).toBeCloseTo(0.3, 5); - expect(buffer.noteInfo[3]).toBeCloseTo(2000, 3); - expect(buffer.noteBorderOpacity[1]).toBeCloseTo(0.8, 5); - - // A 제거 → B가 index0으로 시프트, 테두리 opacity도 따라옴 - buffer.release('a1'); - expect(buffer.activeCount).toBe(1); - expect(buffer.noteInfo[0]).toBeCloseTo(2000, 3); - expect(buffer.noteBorderOpacity[0]).toBeCloseTo(0.8, 5); - - // clear → 슬롯 초기화 - buffer.clear(); - expect(buffer.activeCount).toBe(0); - expect(buffer.noteBorderOpacity[0]).toBe(0); - }); - - it('preserves sub-frame note start times without quantizing to frame steps', () => { - const buffer = createNoteBuffer(); - - buffer.updateTrackLayouts([ - { - trackKey: 'Z', - trackIndex: 0, - position: { dx: 10, dy: 20 }, - width: 60, - height: 60, - noteColor: '#FFFFFF', - noteOpacity: 80, - }, - ]); - - buffer.allocate('Z', 'note-1', 1000.25); - buffer.allocate('Z', 'note-2', 1004.75); - - expect(buffer.noteInfo[0]).toBeCloseTo(1000.25, 3); - expect(buffer.noteInfo[3]).toBeCloseTo(1004.75, 3); - expect(buffer.noteInfo[3] - buffer.noteInfo[0]).toBeCloseTo(4.5, 3); - }); -}); diff --git a/src/renderer/utils/color/colorUtils.test.ts b/src/renderer/utils/color/colorUtils.test.ts index ede3fd38..4b43abcb 100644 --- a/src/renderer/utils/color/colorUtils.test.ts +++ b/src/renderer/utils/color/colorUtils.test.ts @@ -7,41 +7,8 @@ import { toColorObject, toCssRgba, toRgbHexColor, - parseAlphaPercent, - hexWithAlphaPercent, } from './colorUtils'; -describe('parseAlphaPercent', () => { - it('rgba 문자열의 알파를 퍼센트로 추출', () => { - expect(parseAlphaPercent('rgba(255, 0, 0, 0.5)')).toBe(50); - expect(parseAlphaPercent('rgba(0, 0, 0, 1)')).toBe(100); - expect(parseAlphaPercent('rgba(0, 0, 0, 0)')).toBe(0); - }); - - it('8자리 hex의 알파를 추출', () => { - expect(parseAlphaPercent('#FF000080')).toBe(50); - expect(parseAlphaPercent('#FF0000FF')).toBe(100); - }); - - it('알파 없는 입력은 fallback 반환', () => { - expect(parseAlphaPercent('#FF0000', 70)).toBe(70); - expect(parseAlphaPercent(undefined, 100)).toBe(100); - }); -}); - -describe('hexWithAlphaPercent', () => { - it('hex + 퍼센트 → rgba', () => { - expect(hexWithAlphaPercent('#FF0000', 50)).toBe('rgba(255, 0, 0, 0.5)'); - expect(hexWithAlphaPercent('#00FF00', 100)).toBe('rgba(0, 255, 0, 1)'); - }); - - it('parseAlphaPercent와 왕복 일관성', () => { - const css = hexWithAlphaPercent('#123456', 40); - expect(parseAlphaPercent(css)).toBe(40); - expect(toRgbHexColor(css)).toBe('#123456'); - }); -}); - describe('isGradientColor', () => { it('gradient 객체를 감지', () => { expect(