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/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-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/components/main/Grid/PropertiesPanel.tsx b/src/renderer/components/main/Grid/PropertiesPanel.tsx index da083bcb..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', @@ -1745,6 +1757,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, @@ -1882,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; @@ -2019,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, @@ -2046,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 @@ -2086,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'; @@ -2133,6 +2162,18 @@ const PropertiesPanel: React.FC = ({ } else { handleBatchGlowColorChange(newColor); } + } else if (batchPickerFor === 'borderColor') { + // 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); + } } }; @@ -2145,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'; @@ -2186,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 e70f3262..fbf5bedd 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(); @@ -116,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) } @@ -130,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) } @@ -219,14 +219,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 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; @@ -396,6 +403,34 @@ export const BatchKeyLikePanel: React.FC = ({ }; }; + // 테두리 색은 단색만 지원. 피커 열림 시 로컬값, 닫힘 시 실제 공통값/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 +792,7 @@ export const BatchKeyLikePanel: React.FC = ({ } getBatchNoteColorDisplay={getBatchNoteColorDisplay} getBatchGlowColorDisplay={getBatchGlowColorDisplay} + getBatchBorderColorDisplay={getBatchBorderColorDisplay} onNoteColorPickerToggle={() => handleBatchPickerToggle('noteColor') } @@ -772,7 +808,6 @@ export const BatchKeyLikePanel: React.FC = ({ batchNoteColorButtonRef={batchNoteColorButtonRef} batchGlowColorButtonRef={batchGlowColorButtonRef} batchBorderColorButtonRef={batchBorderColorButtonRef} - batchLocalColors={batchLocalColors} t={t} /> 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/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/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.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/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..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(); @@ -438,7 +442,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 +457,18 @@ export default function App() { keyPosition?.noteEffectEnabled !== false; if (keyNoteEffectEnabled) { - requestAnimationFrame(() => { - if (isDown) handleKeyDown(key); - else handleKeyUp(key); - }); + // 실제 입력 시각을 복원해 노트 시작 위치를 보정 (프레임 양자화 방지). + // requestAnimationFrame 래핑 시 노트 생성 시각이 프레임 경계로 양자화돼 + // 주사율/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); } } }); 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(), 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;