Compare commits
2 Commits
14674c4e9a
...
a2bd26682e
| Author | SHA1 | Date | |
|---|---|---|---|
| a2bd26682e | |||
| a588a26144 |
163
docs/superpowers/specs/2026-04-07-pet-lab-design.md
Normal file
163
docs/superpowers/specs/2026-04-07-pet-lab-design.md
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
# Pet Lab - Desktop Pet Application Design
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Windows PC 바탕화면에 항상 떠있는 데스크톱 펫 애플리케이션. 캐릭터(박뚱냥)가 화면 하단에 고정되어 마우스 방향으로 시선을 추적하고, 클릭/우클릭으로 상호작용할 수 있다.
|
||||||
|
|
||||||
|
**프로젝트 위치**: `C:\Users\jaeoh\Desktop\workspace\pet-lab` (독립 프로젝트, web-backend 모노레포 외부)
|
||||||
|
|
||||||
|
**기술 스택**: Python 3.12 + PyQt5
|
||||||
|
|
||||||
|
**배포**: 로컬 Windows PC 실행 전용 (NAS 배포 불필요). 추후 PyInstaller로 .exe 패킹.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
pet-lab/
|
||||||
|
├── app/
|
||||||
|
│ ├── main.py # 엔트리포인트 (QApplication 초기화, 시스템 트레이)
|
||||||
|
│ ├── pet_widget.py # 메인 위젯 (투명 윈도우 + 캐릭터 렌더링)
|
||||||
|
│ ├── eye_tracker.py # 마우스 위치 기반 시선/기울기 계산
|
||||||
|
│ ├── interaction.py # 클릭 반응 애니메이션 + 우클릭 컨텍스트 메뉴
|
||||||
|
│ └── config.py # 설정값 (크기, 위치, 속도 상수)
|
||||||
|
├── assets/
|
||||||
|
│ └── characters/
|
||||||
|
│ └── 박뚱냥.png # 캐릭터 이미지 (투명 배경 PNG)
|
||||||
|
├── requirements.txt # PyQt5
|
||||||
|
└── README.md
|
||||||
|
```
|
||||||
|
|
||||||
|
### Component Responsibilities
|
||||||
|
|
||||||
|
| 파일 | 역할 |
|
||||||
|
|------|------|
|
||||||
|
| `main.py` | QApplication 생성, PetWidget 인스턴스화, 이벤트 루프 시작 |
|
||||||
|
| `pet_widget.py` | 투명 프레임리스 윈도우, 캐릭터 이미지 표시, QTimer 루프로 시선 업데이트 |
|
||||||
|
| `eye_tracker.py` | 마우스 좌표 → 기울기 각도/좌우 반전 여부 계산 (순수 계산 모듈) |
|
||||||
|
| `interaction.py` | 좌클릭(점프), 더블클릭(흔들기) 애니메이션, 우클릭 메뉴 생성/처리 |
|
||||||
|
| `config.py` | 상수 정의: 캐릭터 크기(소/중/대), 틸트 범위, 타이머 간격 등 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Core Behavior
|
||||||
|
|
||||||
|
### 투명 윈도우
|
||||||
|
|
||||||
|
PyQt5 윈도우 플래그 조합:
|
||||||
|
- `Qt.FramelessWindowHint`: 타이틀바 제거
|
||||||
|
- `Qt.WindowStaysOnTopHint`: 항상 위 (토글 가능)
|
||||||
|
- `Qt.Tool`: 태스크바에 표시 안 함
|
||||||
|
- `WA_TranslucentBackground`: 배경 투명
|
||||||
|
|
||||||
|
캐릭터 이미지 영역만 클릭 이벤트 수신. 투명 영역은 `WA_TransparentForMouseEvents`가 아닌, 위젯 크기를 캐릭터 이미지 크기에 맞춰서 처리.
|
||||||
|
|
||||||
|
### 바닥 고정 위치
|
||||||
|
|
||||||
|
- Y = 화면 높이 - 태스크바 높이(기본 48px) - 캐릭터 높이
|
||||||
|
- X = 수평 위치 프리셋: 좌(화면 10%), 중앙(50%), 우(90%)
|
||||||
|
- 기본 위치: 화면 우측(90%)
|
||||||
|
- 태스크바 높이는 Windows API 없이 기본값 48px 사용 (충분히 실용적)
|
||||||
|
|
||||||
|
### 시선 추적
|
||||||
|
|
||||||
|
QTimer(30ms 간격, 약 33fps)로 글로벌 마우스 좌표 폴링:
|
||||||
|
|
||||||
|
1. `QCursor.pos()`로 마우스 절대 좌표 획득
|
||||||
|
2. 캐릭터 중심점과 마우스 사이의 각도 계산 (`math.atan2`)
|
||||||
|
3. 각도를 기울기로 변환:
|
||||||
|
- 마우스가 캐릭터 왼쪽 → 이미지 좌측 기울기 (음수 각도)
|
||||||
|
- 마우스가 캐릭터 오른쪽 → 이미지 우측 기울기 (양수 각도)
|
||||||
|
- 기울기 범위: -15도 ~ +15도
|
||||||
|
4. 마우스가 캐릭터 왼쪽이면 이미지 좌우 반전 (`QTransform.scale(-1, 1)`)
|
||||||
|
5. `QTransform.rotate(angle)`로 기울기 적용
|
||||||
|
6. 마우스 좌표 변화 없으면 렌더링 스킵 (성능 최적화)
|
||||||
|
|
||||||
|
### 클릭 반응
|
||||||
|
|
||||||
|
**좌클릭 — 점프**:
|
||||||
|
- `QPropertyAnimation`으로 위젯 Y좌표를 위로 30px 이동 후 복귀
|
||||||
|
- duration: 300ms, easing: `QEasingCurve.OutBounce`
|
||||||
|
|
||||||
|
**더블클릭 — 흔들기**:
|
||||||
|
- `QPropertyAnimation`으로 X좌표를 좌우 진동
|
||||||
|
- duration: 400ms, 좌(-10) → 우(+10) → 원위치
|
||||||
|
|
||||||
|
### 우클릭 컨텍스트 메뉴
|
||||||
|
|
||||||
|
| 메뉴 항목 | 동작 |
|
||||||
|
|-----------|------|
|
||||||
|
| 위치: 좌/중앙/우 | 캐릭터 수평 위치 변경 |
|
||||||
|
| 크기: 소/중/대 | 캐릭터 크기 변경 (100/150/200px) |
|
||||||
|
| 항상 위 | `WindowStaysOnTopHint` 토글 |
|
||||||
|
| 종료 | 애플리케이션 종료 |
|
||||||
|
|
||||||
|
`QMenu`로 구현. 서브메뉴 사용하여 위치/크기를 그룹화.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Configuration Constants (`config.py`)
|
||||||
|
|
||||||
|
```python
|
||||||
|
# 캐릭터 크기 (높이 기준, 너비는 비율 유지)
|
||||||
|
SIZES = {"small": 100, "medium": 150, "large": 200}
|
||||||
|
DEFAULT_SIZE = "medium"
|
||||||
|
|
||||||
|
# 수평 위치 프리셋 (화면 너비 비율)
|
||||||
|
POSITIONS = {"left": 0.1, "center": 0.5, "right": 0.9}
|
||||||
|
DEFAULT_POSITION = "right"
|
||||||
|
|
||||||
|
# 시선 추적
|
||||||
|
TIMER_INTERVAL_MS = 30 # 약 33fps
|
||||||
|
MAX_TILT_ANGLE = 15.0 # 최대 기울기 (도)
|
||||||
|
|
||||||
|
# 태스크바
|
||||||
|
TASKBAR_HEIGHT = 48 # Windows 기본 태스크바 높이
|
||||||
|
|
||||||
|
# 애니메이션
|
||||||
|
JUMP_HEIGHT = 30 # 점프 높이 (px)
|
||||||
|
JUMP_DURATION_MS = 300
|
||||||
|
SHAKE_OFFSET = 10 # 흔들기 좌우 폭 (px)
|
||||||
|
SHAKE_DURATION_MS = 400
|
||||||
|
|
||||||
|
# 에셋 경로
|
||||||
|
CHARACTER_DIR = "assets/characters"
|
||||||
|
DEFAULT_CHARACTER = "박뚱냥.png"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
```
|
||||||
|
PyQt5>=5.15,<6.0
|
||||||
|
```
|
||||||
|
|
||||||
|
개발 시 추가:
|
||||||
|
```
|
||||||
|
pyinstaller>=6.0 # .exe 패킹용 (나중에)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Constraints
|
||||||
|
|
||||||
|
- **Windows 전용**: PyQt5 투명 윈도우는 Windows에서 가장 안정적. macOS/Linux는 고려하지 않음.
|
||||||
|
- **이미지 1장으로 시작**: 현재 박뚱냥.png 정면 포즈 1장. 시선은 이미지 기울기 + 좌우 반전으로 표현.
|
||||||
|
- **NAS 배포 불필요**: Docker, docker-compose.yml, deploy.sh 수정 없음.
|
||||||
|
- **독립 프로젝트**: `C:\Users\jaeoh\Desktop\workspace\pet-lab`에 별도 Git 저장소.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Future Extensions
|
||||||
|
|
||||||
|
- 스프라이트 시트 추가: idle, walk, sit, sleep 등 포즈별 이미지 → 상태 머신 기반 애니메이션
|
||||||
|
- 자율 행동: 일정 시간 마우스 비활동 시 졸기/잠자기 상태 전환
|
||||||
|
- 시스템 트레이 아이콘: 종료/설정 접근
|
||||||
|
- 설정 파일 저장/로드: JSON으로 크기/위치/캐릭터 선택 영속화
|
||||||
|
- 다중 캐릭터: `assets/characters/` 디렉토리에 여러 캐릭터 추가, 우클릭 메뉴에서 선택
|
||||||
|
- PyInstaller .exe 패킹: 단독 배포용 실행파일 생성
|
||||||
|
- 웹 서비스 연동: pet-lab API 서버 → 캐릭터 다운로드/공유
|
||||||
@@ -72,6 +72,7 @@ def init_db() -> None:
|
|||||||
for col, default in [
|
for col, default in [
|
||||||
("provider", "'local'"), ("lyrics", "''"),
|
("provider", "'local'"), ("lyrics", "''"),
|
||||||
("image_url", "''"), ("suno_id", "''"),
|
("image_url", "''"), ("suno_id", "''"),
|
||||||
|
("file_hash", "''"),
|
||||||
]:
|
]:
|
||||||
try:
|
try:
|
||||||
conn.execute(f"ALTER TABLE music_library ADD COLUMN {col} TEXT NOT NULL DEFAULT {default}")
|
conn.execute(f"ALTER TABLE music_library ADD COLUMN {col} TEXT NOT NULL DEFAULT {default}")
|
||||||
@@ -159,6 +160,7 @@ def _track_row_to_dict(r) -> Dict[str, Any]:
|
|||||||
"lyrics": r["lyrics"] if "lyrics" in keys else "",
|
"lyrics": r["lyrics"] if "lyrics" in keys else "",
|
||||||
"image_url": r["image_url"] if "image_url" in keys else "",
|
"image_url": r["image_url"] if "image_url" in keys else "",
|
||||||
"suno_id": r["suno_id"] if "suno_id" in keys else "",
|
"suno_id": r["suno_id"] if "suno_id" in keys else "",
|
||||||
|
"file_hash": r["file_hash"] if "file_hash" in keys else "",
|
||||||
"created_at": r["created_at"],
|
"created_at": r["created_at"],
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -176,8 +178,8 @@ def add_track(data: Dict[str, Any]) -> Dict[str, Any]:
|
|||||||
INSERT INTO music_library
|
INSERT INTO music_library
|
||||||
(title, genre, moods, instruments, duration_sec, bpm, key, scale,
|
(title, genre, moods, instruments, duration_sec, bpm, key, scale,
|
||||||
prompt, audio_url, file_path, task_id, tags,
|
prompt, audio_url, file_path, task_id, tags,
|
||||||
provider, lyrics, image_url, suno_id)
|
provider, lyrics, image_url, suno_id, file_hash)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
""",
|
""",
|
||||||
(
|
(
|
||||||
data.get("title", ""),
|
data.get("title", ""),
|
||||||
@@ -197,6 +199,7 @@ def add_track(data: Dict[str, Any]) -> Dict[str, Any]:
|
|||||||
data.get("lyrics", ""),
|
data.get("lyrics", ""),
|
||||||
data.get("image_url", ""),
|
data.get("image_url", ""),
|
||||||
data.get("suno_id", ""),
|
data.get("suno_id", ""),
|
||||||
|
data.get("file_hash", ""),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
row = conn.execute("SELECT * FROM music_library WHERE rowid = last_insert_rowid()").fetchone()
|
row = conn.execute("SELECT * FROM music_library WHERE rowid = last_insert_rowid()").fetchone()
|
||||||
@@ -227,6 +230,24 @@ def update_track_duration(track_id: int, duration_sec: int) -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def update_track_file_info(track_id: int, title: str, audio_url: str, file_path: str) -> None:
|
||||||
|
"""파일 rename 시 파일 관련 정보만 업데이트 (태그 등 메타데이터 보존)."""
|
||||||
|
with _conn() as conn:
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE music_library SET title=?, audio_url=?, file_path=? WHERE id=?",
|
||||||
|
(title, audio_url, file_path, track_id),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def update_track_hash(track_id: int, file_hash: str) -> None:
|
||||||
|
"""트랙의 file_hash를 업데이트."""
|
||||||
|
with _conn() as conn:
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE music_library SET file_hash=? WHERE id=?",
|
||||||
|
(file_hash, track_id),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def get_track_file_path(track_id: int) -> Optional[str]:
|
def get_track_file_path(track_id: int) -> Optional[str]:
|
||||||
with _conn() as conn:
|
with _conn() as conn:
|
||||||
row = conn.execute("SELECT file_path FROM music_library WHERE id = ?", (track_id,)).fetchone()
|
row = conn.execute("SELECT file_path FROM music_library WHERE id = ?", (track_id,)).fetchone()
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ from .db import (
|
|||||||
init_db,
|
init_db,
|
||||||
create_task, get_task,
|
create_task, get_task,
|
||||||
get_all_tracks, add_track, delete_track, get_track_file_path, get_track_by_task_id,
|
get_all_tracks, add_track, delete_track, get_track_file_path, get_track_by_task_id,
|
||||||
update_track_duration,
|
update_track_duration, update_track_file_info, update_track_hash,
|
||||||
get_all_lyrics, add_lyrics, update_lyrics, delete_lyrics,
|
get_all_lyrics, add_lyrics, update_lyrics, delete_lyrics,
|
||||||
)
|
)
|
||||||
from .local_provider import run_local_generation
|
from .local_provider import run_local_generation
|
||||||
@@ -203,10 +203,25 @@ def list_library():
|
|||||||
return {"tracks": get_all_tracks()}
|
return {"tracks": get_all_tracks()}
|
||||||
|
|
||||||
|
|
||||||
|
def _calc_file_hash(file_path: str) -> str:
|
||||||
|
"""MD5 해시 계산 (파일 동일성 체크용)."""
|
||||||
|
import hashlib
|
||||||
|
h = hashlib.md5()
|
||||||
|
try:
|
||||||
|
with open(file_path, "rb") as f:
|
||||||
|
for chunk in iter(lambda: f.read(8192), b""):
|
||||||
|
h.update(chunk)
|
||||||
|
return h.hexdigest()
|
||||||
|
except OSError:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
def _sync_library_with_disk():
|
def _sync_library_with_disk():
|
||||||
"""파일시스템의 .mp3 파일과 DB를 동기화.
|
"""파일시스템의 .mp3 파일과 DB를 동기화 (해시 기반 rename 감지).
|
||||||
- 디스크에 없는 트랙 → DB에서 삭제
|
|
||||||
- DB에 없는 .mp3 파일 → 새 트랙으로 추가
|
1단계: 파일명 매칭 (빠른 경로)
|
||||||
|
2단계: 미매칭 파일/레코드를 해시로 비교 → rename 감지 → 메타데이터 보존 업데이트
|
||||||
|
3단계: 나머지 → 삭제/추가
|
||||||
"""
|
"""
|
||||||
tracks = get_all_tracks()
|
tracks = get_all_tracks()
|
||||||
media_base = os.getenv("MUSIC_MEDIA_BASE", "/media/music")
|
media_base = os.getenv("MUSIC_MEDIA_BASE", "/media/music")
|
||||||
@@ -218,32 +233,84 @@ def _sync_library_with_disk():
|
|||||||
if f.lower().endswith(".mp3"):
|
if f.lower().endswith(".mp3"):
|
||||||
disk_files.add(f)
|
disk_files.add(f)
|
||||||
except OSError:
|
except OSError:
|
||||||
return # 디렉토리 접근 불가 시 동기화 스킵
|
return
|
||||||
|
|
||||||
# DB 트랙의 파일명 매핑
|
# ── 1단계: 파일명 매칭 ──────────────────────────────────────
|
||||||
db_filenames = {} # filename → track
|
db_by_filename = {} # filename → track
|
||||||
for t in tracks:
|
for t in tracks:
|
||||||
if t.get("audio_url"):
|
if t.get("audio_url"):
|
||||||
fname = t["audio_url"].split("/")[-1]
|
fname = t["audio_url"].split("/")[-1]
|
||||||
db_filenames[fname] = t
|
db_by_filename[fname] = t
|
||||||
|
|
||||||
# DB에는 있지만 디스크에 없는 → 삭제
|
matched_disk = set()
|
||||||
for fname, t in db_filenames.items():
|
matched_db_ids = set()
|
||||||
if fname not in disk_files:
|
|
||||||
delete_track(t["id"])
|
|
||||||
|
|
||||||
# 디스크에는 있지만 DB에 없는 → 추가 (duration 자동 추출)
|
|
||||||
for f in disk_files:
|
for f in disk_files:
|
||||||
if f not in db_filenames:
|
if f in db_by_filename:
|
||||||
|
matched_disk.add(f)
|
||||||
|
track = db_by_filename[f]
|
||||||
|
matched_db_ids.add(track["id"])
|
||||||
|
# 기존 트랙에 file_hash 없으면 채우기
|
||||||
|
if not track.get("file_hash"):
|
||||||
|
file_hash = _calc_file_hash(os.path.join(MUSIC_DATA_DIR, f))
|
||||||
|
if file_hash:
|
||||||
|
update_track_hash(track["id"], file_hash)
|
||||||
|
|
||||||
|
unmatched_disk = disk_files - matched_disk
|
||||||
|
unmatched_db = [t for t in tracks if t["id"] not in matched_db_ids]
|
||||||
|
|
||||||
|
# ── 2단계: 해시 기반 rename 감지 ────────────────────────────
|
||||||
|
if unmatched_disk and unmatched_db:
|
||||||
|
# DB 미매칭 레코드의 해시 맵
|
||||||
|
db_hash_map = {} # hash → track
|
||||||
|
for t in unmatched_db:
|
||||||
|
h = t.get("file_hash", "")
|
||||||
|
if h:
|
||||||
|
db_hash_map[h] = t
|
||||||
|
|
||||||
|
resolved_disk = set()
|
||||||
|
resolved_db_ids = set()
|
||||||
|
|
||||||
|
for f in unmatched_disk:
|
||||||
file_path = os.path.join(MUSIC_DATA_DIR, f)
|
file_path = os.path.join(MUSIC_DATA_DIR, f)
|
||||||
title = os.path.splitext(f)[0].replace("-", " ").replace("_", " ")
|
file_hash = _calc_file_hash(file_path)
|
||||||
add_track({
|
if not file_hash:
|
||||||
"title": title,
|
continue
|
||||||
"audio_url": f"{media_base}/{f}",
|
|
||||||
"file_path": file_path,
|
if file_hash in db_hash_map:
|
||||||
"provider": "suno",
|
# rename 감지 — 기존 레코드 업데이트 (태그·메타데이터 보존)
|
||||||
"duration_sec": _get_mp3_duration(file_path),
|
track = db_hash_map[file_hash]
|
||||||
})
|
new_title = os.path.splitext(f)[0].replace("-", " ").replace("_", " ")
|
||||||
|
update_track_file_info(
|
||||||
|
track["id"],
|
||||||
|
title=new_title,
|
||||||
|
audio_url=f"{media_base}/{f}",
|
||||||
|
file_path=file_path,
|
||||||
|
)
|
||||||
|
resolved_disk.add(f)
|
||||||
|
resolved_db_ids.add(track["id"])
|
||||||
|
|
||||||
|
unmatched_disk -= resolved_disk
|
||||||
|
unmatched_db = [t for t in unmatched_db if t["id"] not in resolved_db_ids]
|
||||||
|
|
||||||
|
# ── 3단계: 나머지 처리 ──────────────────────────────────────
|
||||||
|
# DB에만 남은 레코드 → 파일 삭제됨 → DB 삭제
|
||||||
|
for t in unmatched_db:
|
||||||
|
delete_track(t["id"])
|
||||||
|
|
||||||
|
# 디스크에만 남은 파일 → 신규 → DB 추가 (해시 포함)
|
||||||
|
for f in unmatched_disk:
|
||||||
|
file_path = os.path.join(MUSIC_DATA_DIR, f)
|
||||||
|
title = os.path.splitext(f)[0].replace("-", " ").replace("_", " ")
|
||||||
|
file_hash = _calc_file_hash(file_path)
|
||||||
|
add_track({
|
||||||
|
"title": title,
|
||||||
|
"audio_url": f"{media_base}/{f}",
|
||||||
|
"file_path": file_path,
|
||||||
|
"provider": "suno",
|
||||||
|
"duration_sec": _get_mp3_duration(file_path),
|
||||||
|
"file_hash": file_hash,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/music/library", status_code=201)
|
@app.post("/api/music/library", status_code=201)
|
||||||
|
|||||||
Reference in New Issue
Block a user