From 201a561fb376d9edc7a01be15f9b5fd29d7f70b8 Mon Sep 17 00:00:00 2001 From: "otis.sh" Date: Fri, 21 Aug 2026 16:49:43 +0900 Subject: [PATCH 1/2] fix: cap the sprite frame rate by decimating frames, not stretching them The always-visible surfaces (menu bar sprite, floating pet) capped their frame rate by raising each frame's delay -- max(floor, delay). That keeps the frame count and stretches the whole loop, so a 55-frame Gen-V sprite (55 x 0.05s = 2.75s) played back over 22s at the 0.4s floor: 1/8 speed. The original rationale ("2.5fps and 5fps are indistinguishable at 22px") only held for the frame rate; it missed that playback slowed 8x. Two-frame bob animations hid the defect, because holding and decimating are the same thing there. GIFDecoder.capFrameRate decimates instead: it emits one frame per accumulated floor-length span, so the loop length matches the original and only the frame rate drops. Same wakeup budget, correct speed, and fewer frames to composite (55 -> 6 at a 0.4s floor). Timer.tolerance drops from 0.5x to 0.1x for the same symptom. Tolerance only ever fires late, so 0.5x declared that the loop may stretch by up to 1.5x -- next to the popover, which runs with tolerance .zero, that made the menu bar look slower on its own. The menu bar cap also moves into a named constant. It was an inline max(0.4, delay) while the pet's cap was a tested constant, so the cap could vanish from the menu bar without any test noticing; both surfaces are now asserted together in testAlwaysVisibleSurfacesAreCapped. Co-Authored-By: Claude Opus 5 (1M context) --- Sources/PokeTokenBar/PokeTokenBarApp.swift | 30 +++++-- Sources/PokeTokenBar/UI/CompanionView.swift | 27 ++++--- Sources/PokeTokenBar/UI/SpriteAnimation.swift | 34 ++++++++ .../PokeTokenBarTests/PerformanceTests.swift | 78 +++++++++++++++---- .../SpriteSubjectTests.swift | 2 +- docs/reference/defect-log.md | 43 ++++++++-- 6 files changed, 177 insertions(+), 37 deletions(-) diff --git a/Sources/PokeTokenBar/PokeTokenBarApp.swift b/Sources/PokeTokenBar/PokeTokenBarApp.swift index da00d6e76..df41e442e 100644 --- a/Sources/PokeTokenBar/PokeTokenBarApp.swift +++ b/Sources/PokeTokenBar/PokeTokenBarApp.swift @@ -192,9 +192,23 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate { // MARK: 메뉴바 애니메이션 + /// 메뉴바 GIF 프레임 지속의 하한(초) = fps 상한. `GIFDecoder.capFrameRate` 가 프레임을 솎아내 + /// 적용한다. **이름 있는 상수로 둔다** — 인라인 리터럴 `max(0.4, delay)` 였을 때는 캡이 통째로 + /// 사라져도 테스트가 잡을 수 없었다(펫 쪽만 상수로 잠겨 있던 가드 비대칭). 값은 튜닝 가능하지만 + /// **0 은 금지**다 — 근거는 defect-log '에너지' 절. 가드: `testAlwaysVisibleSurfacesAreCapped`. + static let menuFrameFloor: TimeInterval = 0.4 // ≈2.5fps + + /// `Timer.tolerance` 배수 — wakeup 코얼레싱(다른 wakeup 과 합쳐 배터리 절약)의 강도. + /// + /// **늦게만 발화시킨다**(Apple: "fire the timer later than the scheduled time, up to the + /// tolerance"). 따라서 이 배수는 곧 애니메이션이 느려질 수 있는 상한이다 — 0.5 였을 때 + /// 2.75s 루프가 최대 4.13s(1.5배)까지 늘어, 정확한 타이밍으로 도는 팝오버(`tolerance: .zero`)와 + /// 나란히 보면 메뉴바만 느려 보였다(2026-08-20). 코얼레싱은 남기고 늘어짐만 눌러 0.1 로 낮춤. + static let menuFrameTolerance = 0.1 + /// 대표 포켓몬에 맞춰 메뉴바 프레임을 준비. 종이 바뀐 경우에만 재로딩. /// 정적 스프라이트로 먼저 보여주고, animated GIF 가 받아지면 교체한다(메뉴바도 GIF로 움직임). - /// 에너지 통제는 ① delay 하한 0.2s(≈5fps) ② 안 보이면 정지(menuShouldAnimate) ③ 저전력 모드 + /// 에너지 통제는 ① delay 하한 `menuFrameFloor`(≈2.5fps) ② 안 보이면 정지(menuShouldAnimate) ③ 저전력 모드 /// 에선 GIF 생략(가벼운 bob)로 처리한다 — 통제된 저프레임 + 비가시 시 정지로 저전력. private func ensureMenuAnimation() { let subject = companion.representativeSubject @@ -223,7 +237,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate { } } - // 풀 GIF 애니메이션(저전력 모드에서는 생략하고 bob 유지). delay 하한 0.1s(≤10fps)로 redraw 통제. + // 풀 GIF 애니메이션(저전력 모드에서는 생략하고 bob 유지). delay 하한 `menuFrameFloor` 로 redraw 통제. guard !ProcessInfo.processInfo.isLowPowerModeEnabled else { return } Task { @MainActor [weak self] in guard let self, gen == self.menuLoadGen else { return } @@ -235,9 +249,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate { guard let data else { return } let raw = GIFDecoder.frames(from: data) guard raw.count > 1, gen == self.menuLoadGen else { return } - // 메뉴바 GIF delay 하한 0.4s(≈2.5fps)로 캡 — 22px 스프라이트엔 5fps와 구분 안 되고, 프레임당 - // 상태바 재합성(CA 커밋 → 디스플레이 사이클 wakeup)을 절반으로 줄여 배터리 절약. bob(0.5s/2fps)과 유사. - self.setMenuFrames(raw.map { (Self.menuBarImage(from: $0.image, up: false), max(0.4, $0.delay)) }) + // fps 캡 = `menuFrameFloor`. 프레임마다 상태바 재합성(CA 커밋 → 디스플레이 사이클 + // wakeup)이 붙으므로 네이티브 fps 로는 절대 돌리지 않는다(근거는 상수 주석). + // 솎아낸 **뒤** 22px 로 합성한다 — 버려질 프레임까지 합성하지 않게. + let capped = GIFDecoder.capFrameRate(raw, floor: Self.menuFrameFloor) + self.setMenuFrames(capped.map { (Self.menuBarImage(from: $0.image, up: false), $0.delay) }) } } @@ -280,7 +296,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate { self.advanceMenu() } } - timer.tolerance = frame.delay * 0.5 // 웨이크업 코얼레싱 (배터리) — 넓힐수록 다른 wakeup 과 합쳐짐 + // 웨이크업 코얼레싱 (배터리). 넓힐수록 다른 wakeup 과 합쳐지지만 그만큼 애니메이션이 + // 늘어질 수 있다 — 배수 근거는 `menuFrameTolerance` 주석. + timer.tolerance = frame.delay * Self.menuFrameTolerance RunLoop.main.add(timer, forMode: .common) menuTimer = timer } diff --git a/Sources/PokeTokenBar/UI/CompanionView.swift b/Sources/PokeTokenBar/UI/CompanionView.swift index 0357ca98a..3f7b5c39d 100644 --- a/Sources/PokeTokenBar/UI/CompanionView.swift +++ b/Sources/PokeTokenBar/UI/CompanionView.swift @@ -49,7 +49,7 @@ struct ItemIconView: View { /// SpriteView 가 그리는 주체(정적 이미지 + 그 이미지가 어느 종의 것인지)의 전이 규칙. /// -/// SwiftUI `.task` 는 호스트 없이 돌릴 수 없어 규칙만 순수 값 전이로 빼 둔다(`frameDelay` 와 같은 방식). +/// SwiftUI `.task` 는 호스트 없이 돌릴 수 없어 규칙만 순수 값 전이로 빼 둔다(`GIFDecoder.capFrameRate` 와 같은 방식). /// 여기 담긴 규칙은 둘 다 "화면에 남은 픽셀이 지금 주체의 것인가"를 지킨다. struct SpriteSubject: Equatable { var image: NSImage? @@ -88,7 +88,9 @@ struct SpriteView: View { var animated: Bool = false var shiny: Bool = false /// GIF 프레임 지속의 하한(초). 0=원본 delay 그대로. >0 이면 fps 상한 + wakeup 코얼레싱을 적용해 - /// idle 배터리를 통제한다 — 항상 떠 있는 플로팅 펫(0.4s≈2.5fps)이 메뉴바 GIF 규율과 동치가 되게. + /// idle 배터리를 통제한다 — 항상 떠 있는 플로팅 펫(`FloatingPetView.frameFloor`)이 메뉴바 GIF + /// (`AppDelegate.menuFrameFloor`)와 **같은 규율**을 쓰게. 규율 = "캡이 존재한다(>0)"이며 값의 + /// 일치가 아니다 — 22px 메뉴바보다 큰 펫은 같은 fps 에서도 끊김이 더 보인다. /// 팝오버 등 일시적 표시는 0(기본)으로 두어 네이티브 fps 유지. var minFrameDelay: TimeInterval = 0 @State private var img: NSImage? @@ -115,8 +117,6 @@ struct SpriteView: View { _loadedShiny = State(initialValue: shiny) } - /// 프레임 지속(초) = max(원본 delay, 하한). 순수·테스트용 — fps 상한 회귀 가드. - static func frameDelay(base: TimeInterval, floor: TimeInterval) -> TimeInterval { max(base, floor) } /// 디코드된 GIF 프레임 중 실제로 재생할 것 — 취소됐거나 2프레임 미만이면 빈 배열(정적 폴백). /// 취소 검사가 여기 있는 이유: `frames` 는 body 에서 `img` 보다 먼저 그려지므로, 취소된 로드가 @@ -137,7 +137,7 @@ struct SpriteView: View { loadedID = next.loadedID } /// 정적 스프라이트를 다시 불러야 하는가 — 종이 바뀌었거나 **이로치 여부가 뒤집혔을 때**. - /// 순수·테스트용(frameDelay 와 같은 이유). 종만 비교하던 과거 판정은 도감의 이로치 토글에서 + /// 순수·테스트용(`GIFDecoder.capFrameRate` 와 같은 이유). 종만 비교하던 과거 판정은 도감의 이로치 토글에서 /// .task 가 다시 돌아도 "이미 그 종을 로드했다"로 판정해 색이 안 바뀌는 회귀를 낳았다. static func needsReload(loadedID: Int?, loadedShiny: Bool, id: Int, shiny: Bool) -> Bool { loadedID != id || loadedShiny != shiny @@ -210,14 +210,19 @@ struct SpriteView: View { // 단일 프레임/디코드 실패 → 정적 폴백. 취소됐으면 아예 반영하지 않는다(빈 배열이라 아래서 종료). let ready = Self.framesToApply(GIFDecoder.frames(from: data), cancelled: Task.isCancelled) guard !ready.isEmpty else { return } - frames = ready + // fps 캡을 여기서 한 번 적용한다(루프에서 프레임마다 늘리면 재생 속도가 느려진다 — + // `GIFDecoder.capFrameRate` 주석 참조). floor=0(팝오버)이면 그대로 통과한다. + frames = GIFDecoder.capFrameRate(ready, floor: minFrameDelay) // delay 기반 프레임 advance. .task 취소 시(speciesID 변경/뷰 소멸) 루프 종료 — 누수 없음 while !Task.isCancelled { - let delay = Self.frameDelay(base: frames[frameIndex % frames.count].delay, floor: minFrameDelay) - // minFrameDelay>0(플로팅 펫): fps 상한 + tolerance 로 wakeup 코얼레싱 — 메뉴바 - // max(0.4,delay)+timer.tolerance 규율과 동치(항상 뜬 표면의 idle 배터리 통제). 0 이면 네이티브. - try? await Task.sleep(for: .seconds(delay), - tolerance: minFrameDelay > 0 ? .seconds(delay * 0.5) : .zero) + let delay = frames[frameIndex % frames.count].delay + // minFrameDelay>0(플로팅 펫): tolerance 로 wakeup 코얼레싱 — 메뉴바 `Timer.tolerance` + // 와 같은 규율(항상 뜬 표면의 idle 배터리 통제). 0 이면 코얼레싱 없이 네이티브. + // 코얼레싱 배수는 메뉴바와 공유한다 — 늦게만 발화하므로 크게 두면 재생이 늘어진다 + // (`AppDelegate.menuFrameTolerance` 주석). + try? await Task.sleep( + for: .seconds(delay), + tolerance: minFrameDelay > 0 ? .seconds(delay * AppDelegate.menuFrameTolerance) : .zero) if Task.isCancelled { break } frameIndex = (frameIndex + 1) % frames.count } diff --git a/Sources/PokeTokenBar/UI/SpriteAnimation.swift b/Sources/PokeTokenBar/UI/SpriteAnimation.swift index 18ccaca9b..86d57525f 100644 --- a/Sources/PokeTokenBar/UI/SpriteAnimation.swift +++ b/Sources/PokeTokenBar/UI/SpriteAnimation.swift @@ -18,6 +18,40 @@ enum GIFDecoder { return out } + /// fps 상한을 **재생 속도를 보존하면서** 적용한다 — 프레임을 늘려 붙이는(hold) 게 아니라 + /// 솎아낸다(decimate). 누적 delay 가 `floor` 를 넘을 때만 그 구간의 첫 프레임을 내보내고 + /// 구간 길이만큼 hold 하므로, 루프 한 바퀴 길이는 원본과 같고 초당 프레임 수만 `1/floor` 로 준다. + /// + /// **왜 `max(floor, delay)` 가 아닌가:** 그 방식은 프레임 수를 그대로 두고 각 프레임을 늘리므로 + /// 애니메이션 **전체가 느려진다**. Gen-V 스프라이트는 55프레임×0.05s(=2.75s, 20fps)라 floor 0.4s + /// 에선 22s 루프 = 1/8 속도가 됐다. 원래 근거였던 "22px 에선 2.5fps 와 5fps 가 구분 안 된다"는 + /// 프레임 레이트에만 맞는 얘기였고, 재생 속도가 8배 늘어나는 건 놓친 것이다(사용자 지적, + /// 2026-08-20 — 메뉴바가 팝오버보다 느리다는 리포트의 실제 원인). 2프레임 bob 에선 프레임 수가 + /// 적어 늘리기와 솎아내기가 같은 결과라 이 결함이 드러나지 않았다. + static func capFrameRate(_ frames: [(image: NSImage, delay: TimeInterval)], + floor: TimeInterval) -> [(image: NSImage, delay: TimeInterval)] { + guard floor > 0, frames.count > 1 else { return frames } + var out: [(image: NSImage, delay: TimeInterval)] = [] + var held: NSImage? // 현재 구간을 대표하는(= 구간 첫) 프레임 + var acc: TimeInterval = 0 // 구간 누적 길이 + for f in frames { + if held == nil { held = f.image } + acc += f.delay + // 부동소수 누적(0.05×4 = 0.20000000000000004 / 0.19999999999999998)에 걸려 구간이 + // 한 프레임씩 밀리지 않게 epsilon 을 둔다. + if acc + 1e-9 >= floor { + out.append((held!, acc)) + held = nil + acc = 0 + } + } + // 마지막 구간이 floor 에 못 미치면 직전 프레임에 합친다 — 총 길이(재생 속도)를 보존한다. + if let tail = held, acc > 0 { + if out.isEmpty { out.append((tail, acc)) } else { out[out.count - 1].delay += acc } + } + return out + } + /// GIF 프레임 delay. unclamped 우선, 너무 짧으면(브라우저 관행) 0.1s 로 보정. private static func delay(_ src: CGImageSource, _ index: Int) -> TimeInterval { guard let props = CGImageSourceCopyPropertiesAtIndex(src, index, nil) as? [CFString: Any], diff --git a/Tests/PokeTokenBarTests/PerformanceTests.swift b/Tests/PokeTokenBarTests/PerformanceTests.swift index 5f555f061..6154699d1 100644 --- a/Tests/PokeTokenBarTests/PerformanceTests.swift +++ b/Tests/PokeTokenBarTests/PerformanceTests.swift @@ -127,17 +127,28 @@ final class StoreTerminationTests: XCTestCase { /// 같은 규율(fps 하한 + 저전력 정적화)을 공유한다. 여기선 그 순수 판정만 고정한다. @MainActor final class FloatingPetEnergyTests: XCTestCase { - /// [회귀] 플로팅 펫 GIF 는 fps 하한(0.4s≈2.5fps)으로 캡 — 네이티브 fps 로 돌면 프레임마다 - /// 재합성(CA 커밋→디스플레이 사이클 wakeup)이 늘어 메뉴바 회귀를 그대로 반복한다. - func testPetFrameDelayHonorsFloor() { - XCTAssertEqual(SpriteView.frameDelay(base: 0.1, floor: 0.4), 0.4, accuracy: 1e-9) // 빠른 프레임 → 캡 - XCTAssertEqual(SpriteView.frameDelay(base: 0.6, floor: 0.4), 0.6, accuracy: 1e-9) // 이미 느리면 원본 유지 + /// [회귀] 상시 표시 표면(메뉴바·펫)의 GIF 는 fps 하한으로 캡 — 네이티브 fps 로 돌면 프레임마다 + /// 재합성(CA 커밋→디스플레이 사이클 wakeup)이 늘어 메뉴바 회귀를 그대로 반복한다. 두 표면을 + /// **함께** 단정한다: 펫 캡만 상수로 잠겨 있고 메뉴바는 인라인 리터럴이던 동안, 메뉴바 캡이 + /// 통째로 사라져도 이 스위트는 통과했다(가드 비대칭). + func testAlwaysVisibleSurfacesAreCapped() { + XCTAssertGreaterThan(AppDelegate.menuFrameFloor, 0, "메뉴바 캡이 풀리면 idle wakeup 회귀") + XCTAssertGreaterThan(FloatingPetView.frameFloor, 0, "펫 캡이 풀리면 idle wakeup 회귀") + for floor in [AppDelegate.menuFrameFloor, FloatingPetView.frameFloor] { + let capped = GIFDecoder.capFrameRate(Self.uniformFrames(count: 20, delay: 0.1), floor: floor) + XCTAssertTrue(capped.allSatisfy { $0.delay >= floor - 1e-9 }, + "floor=\(floor): 빠른 프레임은 캡 이상으로 묶여야 한다") + XCTAssertEqual(capped.reduce(0) { $0 + $1.delay }, 2.0, accuracy: 1e-6, + "floor=\(floor): 속도는 원본 유지") + } } - /// 팝오버 등 일시적 표시(floor=0)는 네이티브 delay 그대로 — 캡은 항상 뜬 펫에만 적용. + /// 팝오버 등 일시적 표시(floor=0)는 네이티브 delay 그대로 — 캡은 항상 뜬 표면에만 적용. func testTransientSpriteKeepsNativeDelay() { - XCTAssertEqual(SpriteView.frameDelay(base: 0.1, floor: 0), 0.1, accuracy: 1e-9) - XCTAssertEqual(SpriteView.frameDelay(base: 0.03, floor: 0), 0.03, accuracy: 1e-9) + let native = Self.uniformFrames(count: 10, delay: 0.03) + let untouched = GIFDecoder.capFrameRate(native, floor: 0) + XCTAssertEqual(untouched.count, 10) + XCTAssertTrue(untouched.allSatisfy { abs($0.delay - 0.03) < 1e-9 }) } /// 저전력 모드면 펫 애니메이션을 정지(정적)해 배터리를 아낀다. 정상 모드면 애니메이션. @@ -146,11 +157,52 @@ final class FloatingPetEnergyTests: XCTestCase { XCTAssertTrue(FloatingPetController.shouldAnimate(lowPower: false)) } - /// [회귀] 펫은 반드시 fps 캡이 걸려야 한다 — frameFloor 가 0 으로 돌아가면(네이티브 fps) 메뉴바에서 - /// 고친 wakeup 회귀가 재발한다. 뷰가 실제로 넘기는 상수를 그대로 가드한다(리터럴 유실 방지). - func testPetFrameFloorIsCapped() { - XCTAssertGreaterThan(FloatingPetView.frameFloor, 0, "펫 fps 캡이 해제되면 idle wakeup 회귀") - XCTAssertEqual(FloatingPetView.frameFloor, 0.4, accuracy: 1e-9, "메뉴바와 동일한 0.4s≈2.5fps 캡") + /// [회귀] fps 캡은 **재생 속도를 보존**해야 한다 — `max(floor, delay)` 로 프레임을 늘려 붙이면 + /// 프레임 수가 그대로라 애니메이션 전체가 느려진다(55프레임×0.05s=2.75s 스프라이트가 floor 0.4s + /// 에서 22s = 1/8 속도). 22px 에서 "끊김이 안 보인다"는 판단은 프레임 레이트에만 맞는 얘기였고 + /// 재생 속도가 8배 늘어나는 건 놓쳤다(사용자 지적, 2026-08-20). 캡은 hold 가 아니라 decimate 다. + func testCapPreservesPlaybackSpeed() { + let native = Self.uniformFrames(count: 55, delay: 0.05) // 2.75s, 20fps — Gen-V 실제 스프라이트 + for floor in [0.2, 0.4] { + let capped = GIFDecoder.capFrameRate(native, floor: floor) + let total = capped.reduce(0) { $0 + $1.delay } + XCTAssertEqual(total, 2.75, accuracy: 1e-6, + "floor=\(floor): 캡을 걸어도 루프 한 바퀴 길이(재생 속도)는 원본과 같아야 한다") + XCTAssertLessThan(Double(capped.count) / total, 1 / floor + 1e-6, + "floor=\(floor): 유효 fps 가 캡을 넘으면 wakeup 회귀") + XCTAssertGreaterThan(capped.count, 1, "floor=\(floor): 애니메이션이 정적으로 붕괴하면 안 된다") + } + } + + /// 이미 느린 GIF(프레임 delay ≥ floor)는 솎아낼 게 없으니 그대로 — 불필요한 변형 금지. + func testCapLeavesAlreadySlowFramesAlone() { + let slow = Self.uniformFrames(count: 4, delay: 0.6) + let capped = GIFDecoder.capFrameRate(slow, floor: 0.4) + XCTAssertEqual(capped.count, 4) + XCTAssertEqual(capped.reduce(0) { $0 + $1.delay }, 2.4, accuracy: 1e-6) + } + + /// floor=0(팝오버 등 일시적 표시)은 네이티브 그대로 — 손대지 않는다. + func testCapIsIdentityAtZeroFloor() { + let native = Self.uniformFrames(count: 55, delay: 0.05) + XCTAssertEqual(GIFDecoder.capFrameRate(native, floor: 0).count, 55) + } + + private static func uniformFrames(count: Int, delay: TimeInterval) + -> [(image: NSImage, delay: TimeInterval)] { + let img = NSImage(size: NSSize(width: 1, height: 1)) + return (0..0) 늘어짐은 눈에 안 띄는 범위(≤15%)로 묶는다. + func testToleranceDoesNotVisiblyStretchPlayback() { + XCTAssertGreaterThan(AppDelegate.menuFrameTolerance, 0, "코얼레싱이 사라지면 wakeup 회귀") + let loop = 2.75 // 41-a.gif 실측 루프 길이 + let worstCase = loop * (1 + AppDelegate.menuFrameTolerance) + XCTAssertLessThanOrEqual(worstCase, loop * 1.15, + "최악의 경우 재생이 15% 이상 늘어지면 팝오버와 속도가 어긋나 보인다") } /// Bubble needs headroom + width beyond the square pet size — otherwise content is clipped. diff --git a/Tests/PokeTokenBarTests/SpriteSubjectTests.swift b/Tests/PokeTokenBarTests/SpriteSubjectTests.swift index 58d6701e2..638f1b613 100644 --- a/Tests/PokeTokenBarTests/SpriteSubjectTests.swift +++ b/Tests/PokeTokenBarTests/SpriteSubjectTests.swift @@ -9,7 +9,7 @@ import AppKit // 2) **취소된 로드는 어떤 상태도 건드리지 않는다.** Swift 의 취소는 협조적이라 `.task(id:)` 가 취소돼도 // await 뒤 코드는 계속 실행된다 — 후속 task 가 이미 새 주체로 잡아 둔 상태를 뒤늦게 덮어쓸 수 있다. // -// SwiftUI `.task` 자체는 호스트 없이 돌릴 수 없어 규칙을 순수 전이로 빼서 검증한다(frameDelay 와 같은 방식). +// SwiftUI `.task` 자체는 호스트 없이 돌릴 수 없어 규칙을 순수 전이로 빼서 검증한다(`GIFDecoder.capFrameRate` 와 같은 방식). // 아래 동시성 테스트는 그 "뒤늦게 도착하는 continuation" 순서를 게이트로 **강제**해 재현한다. /// 테스트가 재개 시점을 쥐는 게이트 — 로드가 await 에서 멈춰 있는 구간을 결정적으로 만든다. diff --git a/docs/reference/defect-log.md b/docs/reference/defect-log.md index 8f103940e..8b7bb502d 100644 --- a/docs/reference/defect-log.md +++ b/docs/reference/defect-log.md @@ -365,16 +365,47 @@ read_when: 검증 함정: bare/`open -n` 보조 인스턴스는 애니메이션이 안 돌아 14%를 **재현 못 함** → 실측은 설치된 primary 앱 교체로만. **배터리(idle wakeup) 차원:** CPU% 낮아도 button.image 대입마다 레이어 dirty → CA 커밋 → WindowServer 디스플레이 사이클 왕복이 wakeup을 증폭한다(실측 ~47 wakeup/s). `setStatusImage` - diff-gate(동일 프레임 객체 재대입 스킵 — 애니 프레임은 서로 다른 객체라 정상 통과) + GIF fps 하한 0.4s(≈2.5fps) - + `Timer.tolerance` 0.5(코얼레싱)로 ~5 wakeup/s(−89%), 애니메이션 유지. 배터리-vs-AC/thermal 적응·CADisplayLink + diff-gate(동일 프레임 객체 재대입 스킵 — 애니 프레임은 서로 다른 객체라 정상 통과) + GIF fps 하한 + (`AppDelegate.menuFrameFloor`, 당시 0.4s≈2.5fps) + `Timer.tolerance` 0.5(코얼레싱)로 ~5 wakeup/s(−89%), + 애니메이션 유지. **캡 값 자체는 튜닝 가능하고 0 만 금지**다 — 세 대책 중 CPU 14%의 주범은 CA 전환·팝오버 + 상주였고 fps 캡의 몫은 wakeup 을 프레임 수에 비례해 줄이는 것뿐이다(CA 전환이 제거된 뒤라 14% 당시와 + 같은 조건이 아니다. 단 **fps 를 올리면 CPU 는 실제로 오른다**: 실측 idle 0.2s/tol 0.5 = 1.8% → + 0.1s/tol 0.1 = 5.1%(2.8배). "CA 전환이 주범이었으니 fps 는 공짜"가 아니라, 주범이 사라진 만큼의 + 여유가 생긴 것뿐이다). + 배터리-vs-AC/thermal 적응·CADisplayLink 전환은 1인 로컬 노트북 기준 수확체감으로 판정, 미도입(필요 시 Agent Team 계획 참조). (Agent Team 조사 + 실측, 2026-07-22.) +- **fps 캡은 hold 가 아니라 decimate 다 — `max(floor, delay)` 는 애니메이션을 슬로모션으로 만든다.** + 프레임 delay 에 하한을 걸면(`max(floor, delay)`) 프레임 *수* 는 그대로라 각 프레임이 늘어나고, 결과적으로 + **루프 전체가 느려진다.** Gen-V 스프라이트는 55프레임×0.05s(=2.75s, 20fps)라 floor 0.4s 에서 22s 루프 + = **1/8 속도**가 됐다(메뉴바·플로팅 펫 둘 다). 올바른 적용은 누적 delay 가 floor 를 넘을 때만 프레임을 + 내보내 **루프 길이를 보존**하는 것(`GIFDecoder.capFrameRate`) — 같은 wakeup 예산에서 속도가 원본이 되고, + floor 0.4s 면 합성할 프레임이 55→6개로 줄어 오히려 가볍다. + **5-whys(왜 안 걸렸나):** ① 원 근거가 "22px 에선 2.5fps 와 5fps 가 구분 안 된다"였는데 이는 *프레임 + 레이트* 에만 맞는 말이고 *재생 속도* 8배 지연은 시야에 없었다. ② 테스트(`frameDelay(base:floor:)`)가 + **프레임 1개 단위**로만 검증해 — `max(0.1,0.4)==0.4` — 프레임이 55개 쌓였을 때 루프가 8배가 되는 걸 + 구조적으로 볼 수 없었다(결함 트리거와 다른 경로로 통과하는 전형). ③ 2프레임 bob 은 늘리기와 솎아내기가 + 같은 결과라 이 결함이 드러나지 않았고, bob 과 GIF 를 같은 하한으로 다룬 게 착시를 굳혔다. + **영구 캡처:** 단일 프레임이 아니라 **루프 총 길이**를 단정한다(`testCapPreservesPlaybackSpeed` — + 옛 hold 방식 주입 시 11.0s/22.0s 를 잡고 실패 확인). 오용된 API `SpriteView.frameDelay` 는 제거했다. + (사용자 리포트 "툴바 포켓몬이 팝오버보다 느리다", 2026-08-20.) +- **`Timer.tolerance`(및 `Task.sleep(tolerance:)`)는 곧 재생 지연의 상한이다.** tolerance 는 **늦게만** + 발화시킨다(Apple: "fire the timer later than the scheduled time, up to the tolerance"). 배수 0.5 는 + 코얼레싱 강도가 아니라 "루프가 최대 1.5배까지 늘어져도 좋다"는 선언이었다 — 2.75s 루프가 최대 4.13s 가 + 되어, `tolerance: .zero` 로 정확히 도는 팝오버와 나란히 보면 메뉴바만 느려 보였다(같은 리포트의 두 번째 + 원인). 상시 애니메이션 표면에서 이 배수를 정할 땐 **wakeup 절약과 재생 지연을 같이 적어라** — 현재 0.1 + (지연 ≤10%). 가드: `testToleranceDoesNotVisiblyStretchPlayback`(>0 로 코얼레싱 유지 + 최악 지연 ≤15%). - **항상 뜬 애니메이션 표면은 메뉴바와 같은 idle 규율을 공유한다.** 플로팅 펫(`FloatingPetPanel`)처럼 상시 - 표시되는 두 번째 GIF 표면을 더할 땐 메뉴바 규율을 그대로 상속해야 회귀(#102 후속)를 안 만든다: ① GIF fps - 하한(`SpriteView(minFrameDelay:)` — 펫은 `FloatingPetView.frameFloor` 0.4s≈2.5fps, 팝오버 등 *일시적* 표시는 - 0=네이티브) + `Task.sleep(for:tolerance:)` 코얼레싱, ② 저전력 모드 정적화(`FloatingPetController.shouldAnimate(lowPower:)` + 표시되는 두 번째 GIF 표면을 더할 땐 메뉴바 규율을 그대로 상속해야 회귀(#102 후속)를 안 만든다. **규율 = + "캡이 존재한다(>0)"이며 값의 일치가 아니다** — 표면이 크면 같은 fps 에서도 끊김이 더 보여 값은 갈릴 수 + 있다: ① GIF fps 하한(`SpriteView(minFrameDelay:)` — 펫은 `FloatingPetView.frameFloor`, 메뉴바는 + `AppDelegate.menuFrameFloor`, 팝오버 등 *일시적* 표시는 0=네이티브) + `Task.sleep(for:tolerance:)` 코얼레싱, ② 저전력 모드 정적화(`FloatingPetController.shouldAnimate(lowPower:)` — `NSProcessInfoPowerStateDidChange` 관찰 후 콘텐츠 재구성), ③ 숨김/슬립 시 `contentView=nil` 로 프레임 루프 정지 (팝오버 `popoverDidClose` 패턴). 회귀 가드: `FloatingPetEnergyTests`(fps 하한 clamp·`frameFloor>0`·팝오버 불변· - low-power 정적화 순수 판정 — SwiftUI `.task` 타이밍 자체는 호스트 없이 xctest 불가라 순수 경로만 잠금). occlusion 게이팅은 + low-power 정적화 순수 판정 — SwiftUI `.task` 타이밍 자체는 호스트 없이 xctest 불가라 순수 경로만 잠금). + **가드 비대칭 주의:** 펫 캡만 상수로 잠겨 있었고 메뉴바 캡은 인라인 리터럴 `max(0.4, delay)` 여서 + *캡이 통째로 사라져도 테스트가 못 잡았다*. 상시 표시 표면을 더할 땐 캡을 반드시 **이름 있는 상수**로 + 두고 두 표면을 한 테스트에서 `>0` 로 단정한다(`testAlwaysVisibleSurfacesAreCapped` — 캡=0 주입으로 + 실패 확인). occlusion 게이팅은 all-spaces/`.floating` 펫이 실제로 거의 안 가려져 메뉴바와 동일 수확체감으로 미도입. (#102 리뷰 지적 반영, 2026-07-22.) ## 알림 From 35754395cb26dd5149b1b0142898f0b972802461 Mon Sep 17 00:00:00 2001 From: "otis.sh" Date: Fri, 21 Aug 2026 16:53:27 +0900 Subject: [PATCH 2/2] feat: let users pick animation quality for the menu bar sprite and floating pet The frame-rate cap on the always-visible surfaces was a single hardcoded value, and no single value fits: the per-frame cost is a status-bar recomposite, so how smooth it looks and how much battery it costs both vary by device and by sprite. A 22px menu bar hides the low frame rate that is clearly visible on a 96px floating pet. This adds a three-preset setting -- Power saver (0.4s / ~2.5fps), Balanced (0.2s / ~5fps), Smooth (0.1s / ~10fps) -- shared by both surfaces, in all four UI languages. No preset can disable the cap; 0 (native fps) is not reachable from the UI, which keeps the idle-wakeup guarantee that the cap exists. The default is Power saver: it is the same frame rate the app used before this setting existed, so an existing user's battery profile does not change and the smoother settings are opt-in. Measured idle CPU for reference: 1.8% at Balanced, 5.1% at Smooth. The floor is part of the frame-cache identity (AppDelegate.menuSpriteKey, SpriteView.frameTaskID) because cached frames are already decimated to a specific rate -- without it, changing the setting would not take effect until the next evolution. The menu bar also observes the setting explicitly: observeStore only tracks menuTitle, so otherwise a change would not show until the next usage poll. Co-Authored-By: Claude Opus 5 (1M context) --- README.ja.md | 1 + README.ko.md | 1 + README.md | 1 + Sources/PokeTokenBar/Core/Localization.swift | 8 +++ Sources/PokeTokenBar/Core/UsageStore.swift | 25 +++++++ Sources/PokeTokenBar/PokeTokenBarApp.swift | 40 +++++++---- Sources/PokeTokenBar/UI/CompanionView.swift | 15 +++-- .../PokeTokenBar/UI/FloatingPetPanel.swift | 4 +- Sources/PokeTokenBar/UI/SettingsView.swift | 15 +++++ .../PokeTokenBarTests/PerformanceTests.swift | 66 +++++++++++++++---- Tests/PokeTokenBarTests/UsageStoreTests.swift | 9 +++ docs/reference/defect-log.md | 21 +++--- 12 files changed, 168 insertions(+), 38 deletions(-) diff --git a/README.ja.md b/README.ja.md index 45bdce786..6090f3bf1 100644 --- a/README.ja.md +++ b/README.ja.md @@ -97,6 +97,7 @@ PokeTokenBar は、あなたがすでに使っている AI コーディングト ## そのほかにも - **代表ポケモン** — 図鑑で所有している種を選び、育成中のポケモンとは別にメニューバーと任意のフローティングペットへ固定。固定中はメニューバーがタマゴ・孵化・進化を追わなくなりますが、育成の進行は Home で引き続き確認できます。 +- **アニメーション品質** — メニューバーのスプライトとフローティングペットの滑らかさを選べます(バッテリー優先/標準/滑らか)。常に表示される2つの表面が同じ設定を共有します。既定の「バッテリー優先」はこの設定が入る前と同じフレームレートで、「標準」「滑らか」はバッテリーを多く使います(実測アイドル CPU 約1.8%/約5.1%)。 - **インタラクティブなフローティングペット** — ホバーで今日の使用量、クリックでメイン画面、右クリックでメニュー、上限アラートは吹き出しで表示。 - **サービス別タブ** — Claude Code・Codex・Gemini CLI・Antigravity・OpenCode・Hermes Agent・Cursor・Grok CLI・Copilot CLI・Kiro CLI のうち2つ以上が検出されると、小さなタブでサービス別の詳細を切替(今日の合計は合算のまま)。 - **公式の上限** — Claude・Codex の5時間/週間使用率とリセットのカウントダウンを、今日の数字のすぐ下に。 diff --git a/README.ko.md b/README.ko.md index f213ac65e..f6d5d79fa 100644 --- a/README.ko.md +++ b/README.ko.md @@ -97,6 +97,7 @@ PokeTokenBar는 당신이 이미 태우고 있는 AI 코딩 토큰(Claude Code ## 이 밖에도 - **대표 포켓몬** — 도감에서 보유한 종을 골라 현재 키우는 포켓몬과 별개로 메뉴바와 선택적 플로팅 펫에 고정. 고정 중에는 메뉴바가 알·부화·진화를 따라가지 않지만, 육성 과정은 Home에서 계속 확인할 수 있습니다. +- **애니메이션 품질** — 메뉴바 스프라이트와 플로팅 펫이 얼마나 부드럽게 움직일지 고릅니다(배터리 절약 / 기본 / 부드럽게). 상시 표시되는 두 표면이 같은 설정을 씁니다. 기본값 "배터리 절약"은 이 설정이 생기기 전과 같은 프레임 레이트이고, "기본"·"부드럽게"는 배터리를 더 씁니다(실측 유휴 CPU 약 1.8% / 약 5.1%). - **인터랙티브 플로팅 펫** — 호버로 오늘 사용량, 클릭으로 메인 창, 우클릭 메뉴, 한도 알림은 말풍선으로 표시. - **서비스별 탭** — Claude Code·Codex·Gemini CLI·Antigravity·OpenCode·Hermes Agent·Cursor·Grok CLI·Copilot CLI·Kiro CLI 중 2개 이상 감지되면 작은 탭으로 상세를 서비스별 전환(오늘 합계는 통합 유지). - **공식 한도** — Claude·Codex 5시간/주간 사용률 + 리셋 카운트다운을 오늘 숫자 바로 아래에. diff --git a/README.md b/README.md index 2e424f4dd..5544ff52e 100644 --- a/README.md +++ b/README.md @@ -97,6 +97,7 @@ The tokens you've already used are your currency. Spend them in the new Shop< ## Also in the box - **Representative Pokémon** — pin any owned Pokédex species to the menu bar and optional floating pet, independently of the companion you're raising. While pinned, the menu bar stops following egg, hatch, and evolution changes; raising progress remains visible on Home. +- **Animation quality** — pick how smoothly the menu bar sprite and the floating pet animate (Power saver / Balanced / Smooth). Both always-visible surfaces share the setting. Power saver is the default and keeps the frame rate the app used before this setting existed; Balanced and Smooth trade battery for smoothness (measured idle CPU ~1.8% and ~5.1%). - **Interactive floating pet** — hover for today's usage, click to open the main window, right-click for a menu, and show limit alerts as speech bubbles. - **Per-service tabs** — when two or more of Claude Code, Codex, Gemini CLI, Antigravity, OpenCode, Hermes Agent, Cursor, Grok CLI, Copilot CLI, and Kiro CLI are detected, compact tabs switch between them; today's total stays combined. - **Official limits** — Claude & Codex 5-hour / weekly utilization with reset countdowns, right under today's numbers. diff --git a/Sources/PokeTokenBar/Core/Localization.swift b/Sources/PokeTokenBar/Core/Localization.swift index 29d3a7d6e..66ffc0f1d 100644 --- a/Sources/PokeTokenBar/Core/Localization.swift +++ b/Sources/PokeTokenBar/Core/Localization.swift @@ -108,6 +108,14 @@ struct L { var todayTokensShort: String { t("오늘 토큰", "Today's tokens", "本日のトークン", "Tokens de hoy") } var todayCost: String { t("오늘 비용 ($)", "Today's cost ($)", "本日のコスト ($)", "Coste de hoy ($)") } var limitPercent: String { t("한도 %", "Limit %", "上限 %", "Límite %") } + var animationQualityLabel: String { t("애니메이션", "Animation", "アニメーション", "Animación") } + var animationQualityHint: String { + t("부드러울수록 배터리를 더 씁니다", "Smoother uses more battery", + "滑らかにするとバッテリー消費が増えます", "Más fluido consume más batería") + } + var animationPowerSaver: String { t("배터리 절약", "Power saver", "バッテリー優先", "Ahorro de batería") } + var animationBalanced: String { t("기본", "Balanced", "標準", "Equilibrado") } + var animationSmooth: String { t("부드럽게", "Smooth", "滑らか", "Fluido") } var limitDisplayModeLabel: String { t("한도 표시 방식", "Limit display", "上限の表示", "Visualización del límite") } var limitDisplayUsed: String { t("사용량", "Used", "使用量", "Usado") } var limitDisplayRemaining: String { t("남은 양", "Remaining", "残量", "Restante") } diff --git a/Sources/PokeTokenBar/Core/UsageStore.swift b/Sources/PokeTokenBar/Core/UsageStore.swift index 79fe09d18..2ea0794ee 100644 --- a/Sources/PokeTokenBar/Core/UsageStore.swift +++ b/Sources/PokeTokenBar/Core/UsageStore.swift @@ -69,6 +69,28 @@ final class UsageStore { var limitDisplayMode: LimitDisplayMode { didSet { defaults.set(limitDisplayMode.rawValue, forKey: "limitDisplayMode") } } + /// 상시 표시 애니메이션(메뉴바 스프라이트 + 플로팅 펫)의 부드러움 ↔ 배터리 절충. + /// + /// 값은 GIF 프레임 지속의 **하한**(초)으로, `GIFDecoder.capFrameRate` 가 프레임을 솎아내 + /// 적용한다 — 재생 속도는 어느 프리셋에서도 원본과 같고 초당 프레임 수만 달라진다. + /// 왜 사용자 선택인가: 프레임당 비용이 상태바 재합성(FrontBoardServices IPC + 렌더 fence)이라 + /// 기기·스프라이트에 따라 체감과 배터리 영향이 갈린다 — 하나의 값으로 모두를 만족시킬 수 없다. + enum AnimationQuality: String, CaseIterable { + case powerSaver, balanced, smooth + + /// 프레임 지속 하한(초) = fps 상한. **0 인 케이스를 만들지 마라** — 네이티브 fps 는 + /// idle wakeup 회귀다(근거는 defect-log '에너지' 절). 가드: `testNoAnimationQualityPresetDisablesTheCap`. + var frameFloor: TimeInterval { + switch self { + case .powerSaver: 0.4 // ≈2.5fps + case .balanced: 0.2 // ≈5fps + case .smooth: 0.1 // ≈10fps + } + } + } + var animationQuality: AnimationQuality { + didSet { defaults.set(animationQuality.rawValue, forKey: "animationQuality") } + } // 알림(독립 토글) var limitNotifications: Bool { didSet { defaults.set(limitNotifications, forKey: "limitNotifications") } @@ -424,6 +446,9 @@ final class UsageStore { floatingPetEnabled = d.object(forKey: "floatingPetEnabled") as? Bool ?? false floatingPetSize = d.object(forKey: "floatingPetSize") as? Double ?? 96 floatingPetBubbleAlerts = d.object(forKey: "floatingPetBubbleAlerts") as? Bool ?? true + // 기본 powerSaver — 이 설정이 생기기 전의 고정 캡(0.4s)과 같은 프레임 레이트라, 기존 + // 사용자의 배터리 프로파일은 그대로다. 더 부드러운 쪽은 opt-in(실측 idle CPU 1.8%/5.1%). + animationQuality = AnimationQuality(rawValue: d.string(forKey: "animationQuality") ?? "") ?? .powerSaver disableKeychainAccess = d.object(forKey: "disableKeychainAccess") as? Bool ?? false reschedule() diff --git a/Sources/PokeTokenBar/PokeTokenBarApp.swift b/Sources/PokeTokenBar/PokeTokenBarApp.swift index df41e442e..d278f069f 100644 --- a/Sources/PokeTokenBar/PokeTokenBarApp.swift +++ b/Sources/PokeTokenBar/PokeTokenBarApp.swift @@ -26,7 +26,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate { // 메뉴바 캐릭터 애니메이션 — 단일 타이머로 프레임 순환. // 프레임 = 이미 22px 로 합성된 이미지 + delay. egg/static 은 2프레임 bob, animated 는 GIF 실제 프레임. - private var menuSpriteKey: String? // "id-shiny" — 같은 종이라도 shiny 여부가 바뀌면 재로딩 + private var menuSpriteKey: String? // menuSpriteKey(id:shiny:floor:) 결과 — 바뀌면 재로딩 private var menuFrames: [(image: NSImage, delay: TimeInterval)] = [] private var menuIndex = 0 private var menuTimer: Timer? @@ -100,13 +100,19 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate { } } - /// 대표 스프라이트 정체성(종/shiny) 관찰 — 대표 선택·해제뿐 아니라 사탕 진화·졸업(BagView), - /// 세이브 가져오기, 부화·메타몽 리빌 async 완료처럼 store 갱신 틱 없이 companion 만 바뀌는 - /// 경로에서도 메뉴바를 즉시 갱신한다. observeStore(menuTitle)만으론 다음 사용량 폴링(기본 120s)까지 + /// 대표 스프라이트 정체성(종/shiny/fps 하한) 관찰 — 대표 선택·해제와 애니메이션 품질 변경뿐 아니라 + /// 사탕 진화·졸업(BagView), 세이브 가져오기, 부화·메타몽 리빌 async 완료처럼 store 갱신 틱 없이 + /// companion 만 바뀌는 경로에서도 메뉴바를 즉시 갱신한다. observeStore(menuTitle)만으론 다음 사용량 폴링(기본 120s)까지 /// 이전 포켓몬이 남는다(사탕 졸업 후 메뉴바 잔상 리포트 — UsageStore.onRefresh 주석과 같은 부류). + /// + /// **fps 설정도 여기서 관찰한다**: 프레임은 하한에 맞춰 솎아낸 결과물이라 하한이 곧 정체성의 + /// 일부다(`menuSpriteKey`). `observeStore` 는 `menuTitle` 만 추적하므로, 이걸 빼면 설정을 + /// 바꿔도 다음 사용량 폴링(기본 120s)까지 옛 fps 로 돈다 — 위 '메뉴바 잔상'과 같은 부류. + /// (플로팅 펫은 `body` 에서 직접 읽어 SwiftUI 관찰이 처리한다.) private func observeCompanionSprite() { withObservationTracking { _ = companion.representativeSubject + _ = store.animationQuality } onChange: { [weak self] in Task { @MainActor in guard let self else { return } @@ -192,11 +198,12 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate { // MARK: 메뉴바 애니메이션 - /// 메뉴바 GIF 프레임 지속의 하한(초) = fps 상한. `GIFDecoder.capFrameRate` 가 프레임을 솎아내 - /// 적용한다. **이름 있는 상수로 둔다** — 인라인 리터럴 `max(0.4, delay)` 였을 때는 캡이 통째로 - /// 사라져도 테스트가 잡을 수 없었다(펫 쪽만 상수로 잠겨 있던 가드 비대칭). 값은 튜닝 가능하지만 - /// **0 은 금지**다 — 근거는 defect-log '에너지' 절. 가드: `testAlwaysVisibleSurfacesAreCapped`. - static let menuFrameFloor: TimeInterval = 0.4 // ≈2.5fps + /// 메뉴바 GIF 프레임 지속의 하한(초) = fps 상한. 사용자 설정 + /// (`UsageStore.AnimationQuality`)이 값을 정하고, `GIFDecoder.capFrameRate` 가 프레임을 + /// 솎아내 적용한다. 하한 자체는 없어질 수 없다 — 근거는 그 enum 과 defect-log '에너지' 절. + /// 히스토리: 0.4s 고정 → 프리셋(0.4/0.2/0.1) 중 사용자 선택. 기기·스프라이트마다 체감과 + /// 배터리 영향이 갈려 하나의 값으로 수렴하지 못했다 — 기본값은 고정 캡과 같은 0.4s 다. + private var menuFrameFloor: TimeInterval { store.animationQuality.frameFloor } /// `Timer.tolerance` 배수 — wakeup 코얼레싱(다른 wakeup 과 합쳐 배터리 절약)의 강도. /// @@ -208,13 +215,13 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate { /// 대표 포켓몬에 맞춰 메뉴바 프레임을 준비. 종이 바뀐 경우에만 재로딩. /// 정적 스프라이트로 먼저 보여주고, animated GIF 가 받아지면 교체한다(메뉴바도 GIF로 움직임). - /// 에너지 통제는 ① delay 하한 `menuFrameFloor`(≈2.5fps) ② 안 보이면 정지(menuShouldAnimate) ③ 저전력 모드 + /// 에너지 통제는 ① delay 하한 `menuFrameFloor` ② 안 보이면 정지(menuShouldAnimate) ③ 저전력 모드 /// 에선 GIF 생략(가벼운 bob)로 처리한다 — 통제된 저프레임 + 비가시 시 정지로 저전력. private func ensureMenuAnimation() { let subject = companion.representativeSubject let id = subject.speciesID let shiny = subject.isShiny - let key = id.map { "\($0)-\(shiny)" } + let key = id.map { Self.menuSpriteKey(id: $0, shiny: shiny, floor: menuFrameFloor) } if key == menuSpriteKey, !menuFrames.isEmpty { return } // 이미 이 개체로 애니메이션 중 menuSpriteKey = key menuLoadGen += 1 @@ -252,7 +259,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate { // fps 캡 = `menuFrameFloor`. 프레임마다 상태바 재합성(CA 커밋 → 디스플레이 사이클 // wakeup)이 붙으므로 네이티브 fps 로는 절대 돌리지 않는다(근거는 상수 주석). // 솎아낸 **뒤** 22px 로 합성한다 — 버려질 프레임까지 합성하지 않게. - let capped = GIFDecoder.capFrameRate(raw, floor: Self.menuFrameFloor) + let capped = GIFDecoder.capFrameRate(raw, floor: self.menuFrameFloor) self.setMenuFrames(capped.map { (Self.menuBarImage(from: $0.image, up: false), $0.delay) }) } } @@ -312,6 +319,15 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate { && (statusItem.button?.window?.occlusionState.contains(.visible) ?? true) } + /// 메뉴바 프레임 캐시의 정체성 — 이 값이 바뀌면 프레임을 다시 만든다. + /// + /// **하한(fps 설정)이 반드시 들어가야 한다.** 프레임은 하한에 맞춰 솎아낸 결과물이라, 키가 + /// 종·이로치만 담으면 설정을 바꿔도 다음 진화까지 옛 fps 로 계속 돈다(설계 시 확인된 함정). + /// 순수·테스트용: `testIdentityKeysIncludeTheFrameFloor`. + static func menuSpriteKey(id: Int, shiny: Bool, floor: TimeInterval) -> String { + "\(id)-\(shiny)-\(floor)" + } + // MARK: 프레임 합성 (22px) /// 스프라이트 정적 + 가벼운 상하 bob 2프레임 (animated 미지원/로딩 폴백). diff --git a/Sources/PokeTokenBar/UI/CompanionView.swift b/Sources/PokeTokenBar/UI/CompanionView.swift index 3f7b5c39d..4997e8512 100644 --- a/Sources/PokeTokenBar/UI/CompanionView.swift +++ b/Sources/PokeTokenBar/UI/CompanionView.swift @@ -88,9 +88,10 @@ struct SpriteView: View { var animated: Bool = false var shiny: Bool = false /// GIF 프레임 지속의 하한(초). 0=원본 delay 그대로. >0 이면 fps 상한 + wakeup 코얼레싱을 적용해 - /// idle 배터리를 통제한다 — 항상 떠 있는 플로팅 펫(`FloatingPetView.frameFloor`)이 메뉴바 GIF - /// (`AppDelegate.menuFrameFloor`)와 **같은 규율**을 쓰게. 규율 = "캡이 존재한다(>0)"이며 값의 - /// 일치가 아니다 — 22px 메뉴바보다 큰 펫은 같은 fps 에서도 끊김이 더 보인다. + /// idle 배터리를 통제한다 — 항상 떠 있는 플로팅 펫과 메뉴바 GIF 가 **같은 규율**을 쓰게. + /// 규율 = "캡이 존재한다(>0)"이며, 두 표면은 지금 같은 사용자 설정 + /// (`UsageStore.AnimationQuality.frameFloor`)을 읽는다. 값이 표면별로 갈릴 수는 있다 — + /// 22px 메뉴바보다 큰 펫은 같은 fps 에서도 끊김이 더 보인다. /// 팝오버 등 일시적 표시는 0(기본)으로 두어 네이티브 fps 유지. var minFrameDelay: TimeInterval = 0 @State private var img: NSImage? @@ -117,6 +118,12 @@ struct SpriteView: View { _loadedShiny = State(initialValue: shiny) } + /// GIF 프레임 로드 task 의 정체성 — 바뀌면 재디코드·재솎아내기. **하한을 포함한다**: + /// 프레임은 하한에 맞춰 솎아낸 결과물이라, 빠지면 fps 설정 변경이 종 교체까지 안 먹는다 + /// (`AppDelegate.menuSpriteKey` 와 같은 이유). 순수·테스트용. + static func frameTaskID(speciesID: Int?, shiny: Bool, floor: TimeInterval) -> String { + "\(speciesID.map(String.init) ?? "nil")-\(shiny)-\(floor)" + } /// 디코드된 GIF 프레임 중 실제로 재생할 것 — 취소됐거나 2프레임 미만이면 빈 배열(정적 폴백). /// 취소 검사가 여기 있는 이유: `frames` 는 body 에서 `img` 보다 먼저 그려지므로, 취소된 로드가 @@ -174,7 +181,7 @@ struct SpriteView: View { } // GIF 재생 중엔 bob 정지(프레임 자체가 움직임) — 폴백/정적일 때만 상하 움직임 .offset(y: bob && frames.isEmpty && up ? -3 : 0) - .task(id: "\(speciesID.map(String.init) ?? "nil")-\(shiny)") { + .task(id: Self.frameTaskID(speciesID: speciesID, shiny: shiny, floor: minFrameDelay)) { // animated 프레임은 id/shiny 변경 시 항상 초기화(이전 개체 프레임 잔상 방지) frames = [] frameIndex = 0 diff --git a/Sources/PokeTokenBar/UI/FloatingPetPanel.swift b/Sources/PokeTokenBar/UI/FloatingPetPanel.swift index 050219c8b..de9140a01 100644 --- a/Sources/PokeTokenBar/UI/FloatingPetPanel.swift +++ b/Sources/PokeTokenBar/UI/FloatingPetPanel.swift @@ -411,7 +411,6 @@ final class PetHostingView: NSHostingView { } struct FloatingPetView: View { - static let frameFloor: TimeInterval = 0.4 var animated: Bool = true @Environment(UsageStore.self) private var store @Environment(CompanionStore.self) private var companion @@ -427,7 +426,8 @@ struct FloatingPetView: View { } SpriteView(speciesID: subject.speciesID, size: size, animated: animated, - shiny: subject.isShiny, minFrameDelay: Self.frameFloor) + shiny: subject.isShiny, + minFrameDelay: store.animationQuality.frameFloor) .frame(width: size, height: size) .zIndex(0) } diff --git a/Sources/PokeTokenBar/UI/SettingsView.swift b/Sources/PokeTokenBar/UI/SettingsView.swift index 2e102bf02..d3b3bbc91 100644 --- a/Sources/PokeTokenBar/UI/SettingsView.swift +++ b/Sources/PokeTokenBar/UI/SettingsView.swift @@ -152,6 +152,21 @@ struct SettingsView: View { .labelsHidden().pickerStyle(.menu).fixedSize() } Divider() + groupRow { + // 메뉴바 스프라이트와 플로팅 펫 **둘 다**에 적용되므로 펫 섹션이 아니라 일반 섹션에 둔다. + VStack(alignment: .leading, spacing: 1) { + Text(l.animationQualityLabel) + Text(l.animationQualityHint).font(.caption2).foregroundStyle(.tertiary) + } + Spacer() + Picker("", selection: $store.animationQuality) { + Text(l.animationPowerSaver).tag(UsageStore.AnimationQuality.powerSaver) + Text(l.animationBalanced).tag(UsageStore.AnimationQuality.balanced) + Text(l.animationSmooth).tag(UsageStore.AnimationQuality.smooth) + } + .labelsHidden().pickerStyle(.menu).fixedSize() + } + Divider() groupRow { Text(l.limitDisplayModeLabel) Spacer() diff --git a/Tests/PokeTokenBarTests/PerformanceTests.swift b/Tests/PokeTokenBarTests/PerformanceTests.swift index 6154699d1..2a8930414 100644 --- a/Tests/PokeTokenBarTests/PerformanceTests.swift +++ b/Tests/PokeTokenBarTests/PerformanceTests.swift @@ -127,19 +127,16 @@ final class StoreTerminationTests: XCTestCase { /// 같은 규율(fps 하한 + 저전력 정적화)을 공유한다. 여기선 그 순수 판정만 고정한다. @MainActor final class FloatingPetEnergyTests: XCTestCase { - /// [회귀] 상시 표시 표면(메뉴바·펫)의 GIF 는 fps 하한으로 캡 — 네이티브 fps 로 돌면 프레임마다 - /// 재합성(CA 커밋→디스플레이 사이클 wakeup)이 늘어 메뉴바 회귀를 그대로 반복한다. 두 표면을 - /// **함께** 단정한다: 펫 캡만 상수로 잠겨 있고 메뉴바는 인라인 리터럴이던 동안, 메뉴바 캡이 - /// 통째로 사라져도 이 스위트는 통과했다(가드 비대칭). - func testAlwaysVisibleSurfacesAreCapped() { - XCTAssertGreaterThan(AppDelegate.menuFrameFloor, 0, "메뉴바 캡이 풀리면 idle wakeup 회귀") - XCTAssertGreaterThan(FloatingPetView.frameFloor, 0, "펫 캡이 풀리면 idle wakeup 회귀") - for floor in [AppDelegate.menuFrameFloor, FloatingPetView.frameFloor] { - let capped = GIFDecoder.capFrameRate(Self.uniformFrames(count: 20, delay: 0.1), floor: floor) - XCTAssertTrue(capped.allSatisfy { $0.delay >= floor - 1e-9 }, - "floor=\(floor): 빠른 프레임은 캡 이상으로 묶여야 한다") + /// [회귀] 상시 표시 표면의 GIF 는 **어떤 프리셋에서도** fps 하한으로 캡 — 네이티브 fps 로 돌면 + /// 프레임마다 재합성(CA 커밋→디스플레이 사이클 wakeup)이 늘어 메뉴바 회귀를 그대로 반복한다. + func testEveryPresetCapsFramesWithoutChangingSpeed() { + for q in UsageStore.AnimationQuality.allCases { + let capped = GIFDecoder.capFrameRate(Self.uniformFrames(count: 20, delay: 0.1), + floor: q.frameFloor) + XCTAssertTrue(capped.allSatisfy { $0.delay >= q.frameFloor - 1e-9 }, + "\(q.rawValue): 빠른 프레임은 캡 이상으로 묶여야 한다") XCTAssertEqual(capped.reduce(0) { $0 + $1.delay }, 2.0, accuracy: 1e-6, - "floor=\(floor): 속도는 원본 유지") + "\(q.rawValue): 속도는 원본 유지") } } @@ -205,6 +202,51 @@ final class FloatingPetEnergyTests: XCTestCase { "최악의 경우 재생이 15% 이상 늘어지면 팝오버와 속도가 어긋나 보인다") } + /// [회귀] **어떤 프리셋도 캡을 해제하지 못한다.** 사용자에게 fps 선택권을 주면서 0(네이티브)이 + /// 새는 게 가장 위험한 회귀 — 프리셋을 추가해도 이 가드가 자동으로 걸린다(개별 상수 단정과 달리). + func testNoAnimationQualityPresetDisablesTheCap() { + XCTAssertFalse(UsageStore.AnimationQuality.allCases.isEmpty) + for q in UsageStore.AnimationQuality.allCases { + XCTAssertGreaterThan(q.frameFloor, 0, "\(q.rawValue): 캡이 해제되면 idle wakeup 회귀") + } + } + + /// 프리셋 순서 계약 — powerSaver 가 가장 느리고(하한 큼) smooth 가 가장 부드럽다. 라벨과 실제 + /// 동작이 어긋나면 사용자가 정반대를 고르게 된다. + func testAnimationQualityPresetsAreOrdered() { + let q = UsageStore.AnimationQuality.self + XCTAssertGreaterThan(q.powerSaver.frameFloor, q.balanced.frameFloor) + XCTAssertGreaterThan(q.balanced.frameFloor, q.smooth.frameFloor) + } + + /// [회귀] 설정을 바꾸면 **즉시** 반영돼야 한다. 두 표면 모두 "정체성이 바뀌면 재로딩" 기계로 + /// 프레임을 갱신하는데, 그 정체성 키에 하한이 빠져 있으면 종이 바뀔 때까지 옛 fps 로 계속 돈다 + /// (`menuSpriteKey` = "id-shiny", `SpriteView.task(id:)` = "id-shiny" 였다 — 설계 시 확인된 함정). + func testIdentityKeysIncludeTheFrameFloor() { + XCTAssertNotEqual(AppDelegate.menuSpriteKey(id: 41, shiny: false, floor: 0.4), + AppDelegate.menuSpriteKey(id: 41, shiny: false, floor: 0.1), + "메뉴바: 하한이 키에 없으면 설정 변경이 안 먹는다") + XCTAssertNotEqual(SpriteView.frameTaskID(speciesID: 41, shiny: false, floor: 0.4), + SpriteView.frameTaskID(speciesID: 41, shiny: false, floor: 0.1), + "펫: 하한이 task id 에 없으면 설정 변경이 안 먹는다") + // 종·이로치 구분은 그대로 유지(하한 추가가 기존 판정을 덮어쓰지 않는다). + XCTAssertNotEqual(AppDelegate.menuSpriteKey(id: 41, shiny: false, floor: 0.2), + AppDelegate.menuSpriteKey(id: 41, shiny: true, floor: 0.2)) + XCTAssertNotEqual(SpriteView.frameTaskID(speciesID: 41, shiny: true, floor: 0.2), + SpriteView.frameTaskID(speciesID: 42, shiny: true, floor: 0.2)) + } + + /// 두 상시 표시 표면(메뉴바·펫)은 이제 **같은 설정값**을 읽는다(`animationQuality.frameFloor`) + /// — 한쪽만 캡이 풀리는 비대칭이 구조적으로 불가능해졌다. 남은 위험은 호출부가 0 을 직접 + /// 넘기는 것뿐인데, 두 호출부 모두 SwiftUI/AppKit 뷰라 헤드리스로 잡을 수 없어 여기선 + /// 프리셋 자체의 계약만 잠근다(`testNoAnimationQualityPresetDisablesTheCap`). + /// 일시적 표시(팝오버)는 의도적으로 floor 0 = 네이티브 fps 다. + func testTransientSurfaceIsTheOnlyUncappedOne() { + XCTAssertEqual(GIFDecoder.capFrameRate(Self.uniformFrames(count: 10, delay: 0.03), + floor: 0).count, 10, "팝오버는 네이티브 유지") + XCTAssertTrue(UsageStore.AnimationQuality.allCases.allSatisfy { $0.frameFloor > 0 }) + } + /// Bubble needs headroom + width beyond the square pet size — otherwise content is clipped. func testPanelGrowsForBubbleWithoutChangingPetOrigin() { let pet: CGFloat = 96 diff --git a/Tests/PokeTokenBarTests/UsageStoreTests.swift b/Tests/PokeTokenBarTests/UsageStoreTests.swift index 5a4953d0f..f69aadd7b 100644 --- a/Tests/PokeTokenBarTests/UsageStoreTests.swift +++ b/Tests/PokeTokenBarTests/UsageStoreTests.swift @@ -564,6 +564,15 @@ final class UsageStoreTests: XCTestCase { XCTAssertEqual(s2.limitDisplayMode, .remaining, "같은 defaults 재로드 → 유지") } + /// 설정 영속 — 기본은 powerSaver(이 설정 이전의 고정 캡과 동일), 선택은 재시작 후 유지. + func testAnimationQualityPersistsAcrossRestart() { + let s1 = makeStore(providers: []) + XCTAssertEqual(s1.animationQuality, .powerSaver, "기본값 = powerSaver(기존 동작 보존)") + s1.animationQuality = .smooth + let s2 = makeStore(providers: []) + XCTAssertEqual(s2.animationQuality, .smooth, "같은 defaults 재로드 → 유지") + } + // MARK: 집계 func testAggregatesTodayTokensAcrossProviders() async { diff --git a/docs/reference/defect-log.md b/docs/reference/defect-log.md index 8b7bb502d..721fd3684 100644 --- a/docs/reference/defect-log.md +++ b/docs/reference/defect-log.md @@ -368,10 +368,12 @@ read_when: diff-gate(동일 프레임 객체 재대입 스킵 — 애니 프레임은 서로 다른 객체라 정상 통과) + GIF fps 하한 (`AppDelegate.menuFrameFloor`, 당시 0.4s≈2.5fps) + `Timer.tolerance` 0.5(코얼레싱)로 ~5 wakeup/s(−89%), 애니메이션 유지. **캡 값 자체는 튜닝 가능하고 0 만 금지**다 — 세 대책 중 CPU 14%의 주범은 CA 전환·팝오버 - 상주였고 fps 캡의 몫은 wakeup 을 프레임 수에 비례해 줄이는 것뿐이다(CA 전환이 제거된 뒤라 14% 당시와 - 같은 조건이 아니다. 단 **fps 를 올리면 CPU 는 실제로 오른다**: 실측 idle 0.2s/tol 0.5 = 1.8% → - 0.1s/tol 0.1 = 5.1%(2.8배). "CA 전환이 주범이었으니 fps 는 공짜"가 아니라, 주범이 사라진 만큼의 - 여유가 생긴 것뿐이다). + 상주였고 fps 캡의 몫은 wakeup 을 프레임 수에 비례해 줄이는 것뿐이다(22px 에서도 2.5fps 는 느리다는 + 지적이 있어 값을 사용자 선택으로 열었다 — `UsageStore.AnimationQuality` 의 0.4/0.2/0.1. CA 전환이 + 제거된 뒤라 14% 당시와 같은 조건이 아니다. 단 **fps 를 올리면 CPU 는 실제로 오른다**: 실측 idle + 0.2s/tol 0.5 = 1.8% → 0.1s/tol 0.1 = 5.1%(2.8배). "CA 전환이 주범이었으니 fps 는 공짜"가 아니라, + 주범이 사라진 만큼의 여유가 생긴 것뿐이다. 그래서 **기본값은 이 설정 이전과 같은 0.4s(powerSaver)** + 이고, 더 부드러운 쪽은 opt-in 이다). 배터리-vs-AC/thermal 적응·CADisplayLink 전환은 1인 로컬 노트북 기준 수확체감으로 판정, 미도입(필요 시 Agent Team 계획 참조). (Agent Team 조사 + 실측, 2026-07-22.) - **fps 캡은 hold 가 아니라 decimate 다 — `max(floor, delay)` 는 애니메이션을 슬로모션으로 만든다.** @@ -397,15 +399,18 @@ read_when: - **항상 뜬 애니메이션 표면은 메뉴바와 같은 idle 규율을 공유한다.** 플로팅 펫(`FloatingPetPanel`)처럼 상시 표시되는 두 번째 GIF 표면을 더할 땐 메뉴바 규율을 그대로 상속해야 회귀(#102 후속)를 안 만든다. **규율 = "캡이 존재한다(>0)"이며 값의 일치가 아니다** — 표면이 크면 같은 fps 에서도 끊김이 더 보여 값은 갈릴 수 - 있다: ① GIF fps 하한(`SpriteView(minFrameDelay:)` — 펫은 `FloatingPetView.frameFloor`, 메뉴바는 + 있다(현재는 두 표면이 사용자 설정 `UsageStore.AnimationQuality` 하나를 공유한다 — 표면별로 값을 + 갈라야 할 근거가 생기면 그때 프리셋에서 표면별 값을 뽑아라): ① GIF fps 하한 + (`SpriteView(minFrameDelay:)` — 펫은 `store.animationQuality.frameFloor`, 메뉴바는 `AppDelegate.menuFrameFloor`, 팝오버 등 *일시적* 표시는 0=네이티브) + `Task.sleep(for:tolerance:)` 코얼레싱, ② 저전력 모드 정적화(`FloatingPetController.shouldAnimate(lowPower:)` — `NSProcessInfoPowerStateDidChange` 관찰 후 콘텐츠 재구성), ③ 숨김/슬립 시 `contentView=nil` 로 프레임 루프 정지 (팝오버 `popoverDidClose` 패턴). 회귀 가드: `FloatingPetEnergyTests`(fps 하한 clamp·`frameFloor>0`·팝오버 불변· low-power 정적화 순수 판정 — SwiftUI `.task` 타이밍 자체는 호스트 없이 xctest 불가라 순수 경로만 잠금). **가드 비대칭 주의:** 펫 캡만 상수로 잠겨 있었고 메뉴바 캡은 인라인 리터럴 `max(0.4, delay)` 여서 - *캡이 통째로 사라져도 테스트가 못 잡았다*. 상시 표시 표면을 더할 땐 캡을 반드시 **이름 있는 상수**로 - 두고 두 표면을 한 테스트에서 `>0` 로 단정한다(`testAlwaysVisibleSurfacesAreCapped` — 캡=0 주입으로 - 실패 확인). occlusion 게이팅은 + *캡이 통째로 사라져도 테스트가 못 잡았다*. 지금은 두 표면이 같은 프리셋 값을 읽어 비대칭 자체가 + 구조적으로 불가능하고, 가드는 프리셋 계약에 걸린다(`testNoAnimationQualityPresetDisablesTheCap`, + `testTransientSurfaceIsTheOnlyUncappedOne` — 캡=0 주입으로 실패 확인). 상시 표시 표면을 더할 땐 + 캡을 반드시 **이름 있는 값**으로 두고 `>0` 를 단정한다. occlusion 게이팅은 all-spaces/`.floating` 펫이 실제로 거의 안 가려져 메뉴바와 동일 수확체감으로 미도입. (#102 리뷰 지적 반영, 2026-07-22.) ## 알림