2 Commits

Author SHA1 Message Date
a2bd26682e feat(music-lab): 파일 해시 기반 라이브러리 동기화 — rename 시 태그 보존
- music_library에 file_hash(MD5) 컬럼 추가
- _sync_library_with_disk를 3단계로 변경:
  1. 파일명 매칭 (빠른 경로)
  2. 해시 비교로 rename 감지 → 기존 레코드 업데이트 (태그 보존)
  3. 나머지 → 삭제/추가
- 파일명 변경 시 audio_url 업데이트 → 다운로드도 새 이름 적용

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-07 03:26:41 +09:00
a588a26144 docs: pet-lab 데스크톱 펫 애플리케이션 설계 문서 추가
PyQt5 기반 Windows 데스크톱 펫 — 화면 하단 고정, 마우스 시선 추적,
클릭/우클릭 상호작용. 독립 프로젝트(workspace/pet-lab)로 분리.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-07 03:24:00 +09:00
3 changed files with 275 additions and 24 deletions

View 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 서버 → 캐릭터 다운로드/공유

View File

@@ -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()

View File

@@ -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)