claude-ai-office 0.31.0 → 0.34.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.en.md +14 -6
- package/README.md +36 -10
- package/docs/ARCHITECTURE.md +6 -2
- package/package.json +1 -1
- package/public/assets/characters/acc-glasses.webp +0 -0
- package/public/assets/characters/acc-hearing.webp +0 -0
- package/public/assets/characters/acc-mask.webp +0 -0
- package/public/assets/characters/acc-sunglasses.webp +0 -0
- package/public/assets/characters/female-a.mask.webp +0 -0
- package/public/assets/characters/female-a.webp +0 -0
- package/public/assets/characters/female-b.mask.webp +0 -0
- package/public/assets/characters/female-b.webp +0 -0
- package/public/assets/characters/female-c.mask.webp +0 -0
- package/public/assets/characters/female-c.webp +0 -0
- package/public/assets/characters/female-d.mask.webp +0 -0
- package/public/assets/characters/female-d.webp +0 -0
- package/public/assets/characters/female-e.mask.webp +0 -0
- package/public/assets/characters/female-e.webp +0 -0
- package/public/assets/characters/female-f.mask.webp +0 -0
- package/public/assets/characters/female-f.webp +0 -0
- package/public/assets/characters/male-a.mask.webp +0 -0
- package/public/assets/characters/male-a.webp +0 -0
- package/public/assets/characters/male-b.mask.webp +0 -0
- package/public/assets/characters/male-b.webp +0 -0
- package/public/assets/characters/male-c.mask.webp +0 -0
- package/public/assets/characters/male-c.webp +0 -0
- package/public/assets/characters/male-d.mask.webp +0 -0
- package/public/assets/characters/male-d.webp +0 -0
- package/public/assets/characters/male-e.mask.webp +0 -0
- package/public/assets/characters/male-e.webp +0 -0
- package/public/assets/characters/male-f.mask.webp +0 -0
- package/public/assets/characters/male-f.webp +0 -0
- package/public/index.html +1 -1
- package/public/js/avatarStore.js +20 -17
- package/public/js/customCharacters.js +133 -0
- package/public/js/customize.js +148 -64
- package/public/js/i18n.js +16 -3
- package/public/js/scene/actors.js +28 -33
- package/public/js/scene/characters.js +309 -0
- package/public/style.css +34 -12
- package/scripts/bake-characters/bake.js +79 -0
- package/scripts/bake-characters/render.html +243 -0
- package/server.js +1 -0
- package/public/js/scene/avatar.js +0 -294
package/README.en.md
CHANGED
|
@@ -138,14 +138,22 @@ Shortcuts work when no input field has focus.
|
|
|
138
138
|
|
|
139
139
|
## Character looks and names
|
|
140
140
|
|
|
141
|
-

|
|
142
142
|
|
|
143
|
-
|
|
143
|
+
Characters are the 12 [Kenney Mini Characters](https://kenney.nl/assets/mini-characters) (CC0), baked at the office's isometric angle. They walk, sit in rest rooms, type while editing or running commands, and nod with a `!` when waiting for you. Subagents wear a yellow hard hat.
|
|
144
144
|
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
145
|
+
⚙️ Settings → **Character looks** picks one of the 12 for four targets separately: **me / default Claude / default subagent / the Claude of the session you are viewing**. Claude targets default to **Auto** (a different character per session, the same in every browser). 🎲 random (a whole character or a mix of parts), ↺ reset, 🗑 delete the saved choice. Saved in this browser only.
|
|
146
|
+
|
|
147
|
+
**Mix parts**: the parts rows in the same window change a character piece by piece — any of the 12 **heads** on any of the 12 **bodies**, **hair / top / bottom colors** (original, a palette, or any color from the picker; the original shading pattern is kept), and an **accessory** (glasses, sunglasses, mask, hearing aid from the Kenney pack). Walking, sitting, typing, nodding and the subagent hard hat all work with mixes.
|
|
148
|
+
|
|
149
|
+

|
|
150
|
+
|
|
151
|
+
**My characters (your own pictures)**: click **+ Upload PNG** or drop a file anywhere on the customizer to use a picture with a transparent background.
|
|
152
|
+
|
|
153
|
+
- The transparent margin is trimmed and the long side scaled to 256 px, then stored in this browser's localStorage (several pictures, up to 24 / about 2.5 MB). Hover a tile and press **×** twice to delete it; targets using it go back to their default.
|
|
154
|
+
- Any target can use one. **Auto** never picks an uploaded picture.
|
|
155
|
+
- It is a single still image, so the whole body moves: a bounce and slight tilt per step, lower when sitting, a small jitter while typing, a bow when nodding. It flips with the walking direction, and a subagent's hard hat sits on the top of the head (found from the alpha channel).
|
|
156
|
+
- Non-images, files over 10 MB or 8192 px, and full storage show a notice in the customizer. A picture without transparency still works but shows as a rectangle.
|
|
149
157
|
|
|
150
158
|
**Names per session**: every session's character gets an automatic nickname (Alex, Sam, Max, Leo, Mia, Zoe, …). Rename it in the "Character" row of the session card or in the customizer (🎲 draws another nickname; clearing the field goes back to automatic). The log and tooltips use that name. The server still calls it `Claude`; only the screen changes.
|
|
151
159
|
|
package/README.md
CHANGED
|
@@ -138,16 +138,40 @@ node bin/ai-office.js # 또는 npm run install-hooks && npm start
|
|
|
138
138
|
| `Esc` | 단축키 패널 · 외형 모달 · 설정 메뉴 · 전체 보기 닫기 → 검색창 비우기 → 따라가기 해제 (순서대로 하나씩) |
|
|
139
139
|
| `?` | 단축키 패널 열기/닫기 |
|
|
140
140
|
|
|
141
|
-
##
|
|
141
|
+
## 캐릭터와 이름
|
|
142
142
|
|
|
143
|
-

|
|
144
144
|
|
|
145
|
-
|
|
145
|
+
캐릭터는 [Kenney Mini Characters](https://kenney.nl/assets/mini-characters)(CC0) 12명을 사무실과 같은 각도(2:1 아이소메트릭)로 구운 스프라이트입니다. 상태에 따라 동작이 바뀝니다: 이동 중 **걷기**, 휴식 방에서 **앉기**, 편집·실행 중 **타이핑**, 답을 기다리면 **끄덕임** + 머리 위 느낌표. 서브에이전트는 노란 **안전모**를 씁니다.
|
|
146
146
|
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
147
|
+

|
|
148
|
+
|
|
149
|
+
⚙️ 설정 → **캐릭터 외형** 에서 **나 / Claude 기본 / 서브에이전트 기본 / 지금 보는 층의 Claude** 네 대상마다 12명 중 한 명을 고릅니다.
|
|
150
|
+
|
|
151
|
+
- Claude 계열은 기본이 **자동**: 세션마다 다른 캐릭터가 배정되고(세션 id 해시), 어느 브라우저에서도 같은 세션은 같은 캐릭터입니다.
|
|
152
|
+
- 🎲 랜덤(통째 캐릭터 또는 파트 조합), ↺ 초기화, 🗑 저장 삭제. 저장은 이 브라우저에만 남는다.
|
|
153
|
+
|
|
154
|
+
**파트 바꾸기**: 같은 창의 파트 줄에서 캐릭터를 부위별로 고칩니다.
|
|
155
|
+
|
|
156
|
+

|
|
157
|
+
|
|
158
|
+
- **머리 · 몸**: 12명 중 아무 머리와 아무 몸을 섞는다 (예: female-b 의 머리 + male-d 의 정장).
|
|
159
|
+
- **머리카락 · 상의 · 하의 색**: 원래 색, 팔레트, 또는 색 고르개로 아무 색. 원래 무늬의 밝고 어두움은 그대로 남는다 (넥타이·칼라처럼 다른 색 부분은 원래 색).
|
|
160
|
+
- **소품**: 안경 · 선글라스 · 마스크 · 보청기 (Kenney 팩에 들어 있는 모델).
|
|
161
|
+
- 걷기·앉기·타이핑·끄덕임과 서브에이전트 안전모도 조합 그대로 움직입니다.
|
|
162
|
+
|
|
163
|
+

|
|
164
|
+
|
|
165
|
+
**내 캐릭터 (직접 올린 그림)**: 같은 창의 **+ PNG 올리기** 타일을 누르거나 그 위(창 어디든)에 파일을 끌어다 놓으면, 배경이 투명한 그림을 캐릭터로 씁니다.
|
|
166
|
+
|
|
167
|
+

|
|
168
|
+
|
|
169
|
+
- 올린 그림은 투명 여백을 잘라내고 긴 변을 256px 로 줄여 이 브라우저 localStorage 에 저장됩니다(여러 장, 최대 24장·합계 약 2.5MB). 타일에 마우스를 올려 **×** 를 두 번 누르면 지워지고, 그 그림을 쓰던 대상은 기본값으로 돌아갑니다.
|
|
170
|
+
- 네 대상 어디에나 쓸 수 있습니다. **자동**은 Kenney 12명 중에서만 고르고 올린 그림은 섞지 않습니다.
|
|
171
|
+
- 정지 그림 한 장이라 동작은 몸 전체로 보여 줍니다: 걷기는 걸음마다 통통 튀며 살짝 기울고, 앉으면 낮아지고, 타이핑은 잔떨림, 끄덕임은 숙였다 펴기. 진행 방향에 따라 좌우가 뒤집히고, 서브에이전트면 그림의 머리 꼭대기(알파로 찾음)에 안전모가 얹힙니다.
|
|
172
|
+
- 이미지가 아니거나(파일 10MB·한 변 8192px 초과 포함) 저장 공간이 모자라면 창 안에 안내가 나옵니다. 배경이 투명하지 않은 그림도 쓸 수는 있지만 사각형 그대로 보입니다.
|
|
173
|
+
|
|
174
|
+

|
|
151
175
|
|
|
152
176
|
**세션별 이름**: 세션마다 캐릭터에 자동 별명(Alex, Sam, Max, Leo, Mia, Zoe, …)이 붙고, 우측 세션 카드의 "캐릭터" 행이나 외형 모달의 이름 행에서 직접 이름을 지을 수 있습니다(🎲 로 다른 별명 뽑기, 비우면 자동 별명으로). 활동 로그와 툴팁도 그 이름으로 표시됩니다. 서버가 보는 이름은 그대로 `Claude` 이고 화면에서만 바뀝니다.
|
|
153
177
|
|
|
@@ -182,8 +206,8 @@ Claude Code 세션 ─hooks─▶ hooks/emit.js ─POST /hook─▶ server.js
|
|
|
182
206
|
- `lib/normalize.js` 훅 payload 축소 + 도구 → 방/말풍선 해석
|
|
183
207
|
- `lib/state.js` 층/세션/캐릭터 상태 머신, `lib/project.js` cwd → git 저장소·브랜치·worktree
|
|
184
208
|
- `lib/journal.js` 이벤트 JSONL 기록(`~/.ai-office/events.jsonl`) + 기동 시 재생, 주기 압축
|
|
185
|
-
- `public/js/scene/*` 아이소메트릭 맵·가구·캐릭터(
|
|
186
|
-
- `public/js/overview.js` 전체 보기, `customize.js`
|
|
209
|
+
- `public/js/scene/*` 아이소메트릭 맵·가구·캐릭터(스프라이트 `characters.js`, 시트는 `public/assets/characters/`)·A*·카메라·시간대 조명(`daylight.js`)·층 스타일(`floorStyle.js`)
|
|
210
|
+
- `public/js/overview.js` 전체 보기, `customize.js` 캐릭터 고르기 모달, `names.js` 세션별 이름, `panels.js` 좌/우 패널
|
|
187
211
|
- `bin/ai-office.js` CLI, `scripts/install-hooks.js` 훅 등록/제거, `scripts/demo.js` 데모
|
|
188
212
|
|
|
189
213
|
외부 의존성 0, 번들러 없음. 자세한 설계는 [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md).
|
|
@@ -191,7 +215,7 @@ Claude Code 세션 ─hooks─▶ hooks/emit.js ─POST /hook─▶ server.js
|
|
|
191
215
|
## 개발
|
|
192
216
|
|
|
193
217
|
```bash
|
|
194
|
-
npm test # node --test (상태 머신 · 경로 탐색 ·
|
|
218
|
+
npm test # node --test (상태 머신 · 경로 탐색 · 캐릭터 · 이름 · CLI …)
|
|
195
219
|
AI_OFFICE_PORT=4799 npm start # 다른 포트로 띄우기 (훅도 같은 포트로 등록해야 보임)
|
|
196
220
|
npm run demo # 서버가 떠 있는 상태에서 가짜 세션 흘려 넣기 (DEMO_SPEED=3 으로 빠르게)
|
|
197
221
|
```
|
|
@@ -205,4 +229,6 @@ npm run bump:minor # 0.19.0 → 0.20.0
|
|
|
205
229
|
|
|
206
230
|
릴리스: `git tag vX.Y.Z && git push origin vX.Y.Z` 를 올리면 GitHub Actions 가 맥·Windows 앱을 빌드해 Release 에 올리고(updater 용 `latest.json` 포함), Windows 러너에서 설치 → 실행 → 훅까지 확인합니다. 테스트는 push·PR 마다 Ubuntu·Windows·macOS 에서 돕니다.
|
|
207
231
|
|
|
232
|
+
캐릭터 스프라이트 다시 굽기: [Kenney Mini Characters](https://kenney.nl/assets/mini-characters) 팩을 받아 풀고 `node scripts/bake-characters/bake.js <팩 폴더>` (헤드리스 크롬 + three.js CDN, 결과는 `public/assets/characters/*.webp`). 캐릭터마다 색 시트(머리 레이어 + 몸통 레이어)와 마스크 시트(머리카락·상의·하의 음영), 소품마다 시트 하나를 굽는다. 시트 구성(프레임 크기·동작 행·레이어)과 소품 위치는 `characters.js` 의 `SHEET`·`ACCESSORIES` 에서 정한다.
|
|
233
|
+
|
|
208
234
|
작업 흐름: GitHub 이슈를 `[FE-30]`, `[BE-13]`, `[APP-03]` 처럼 FE/BE/APP 으로 나눠 만들고, `main` 에서 `feature/FE-30` 식 브랜치를 분리해 작업한 뒤 결과·테스트·캡처를 이슈 댓글로 남긴다.
|
package/docs/ARCHITECTURE.md
CHANGED
|
@@ -168,6 +168,7 @@ public/
|
|
|
168
168
|
index.html 3단 레이아웃 (좌 층별 안내 / 중앙 canvas / 우 사람들·로그), 상단 헤더(층·세션 제목·브랜치·LIVE)
|
|
169
169
|
style.css 라이트/다크 테마 토큰, 패널 카드
|
|
170
170
|
fonts/ 서브셋 웹폰트 (scripts/subset-fonts.sh)
|
|
171
|
+
assets/characters/ 캐릭터 스프라이트 webp: 12명 색 시트(머리·몸통 레이어) + 마스크 시트(머리카락·상의·하의 음영) + 소품 4장 (scripts/bake-characters 로 굽는다)
|
|
171
172
|
js/
|
|
172
173
|
main.js SSE 연결·재접속, store 갱신, 알림(소리·브라우저 알림), 설정, 키보드(1~9 층 이동)
|
|
173
174
|
store.js floors/sessions/log 미러, 선택된 층/세션, 층 접기(auto/all/none), subscribe()
|
|
@@ -175,7 +176,8 @@ public/
|
|
|
175
176
|
overview.js 전체 보기: 층을 건물처럼 쌓아 미니 사무실 + 캐릭터 점
|
|
176
177
|
i18n.js 언어: 한국어 원문, 영어는 사전 → 패턴 규칙 → 구절 치환. 사용자 요청·제목은 번역하지 않는다
|
|
177
178
|
names.js 세션별 캐릭터 별명 (localStorage)
|
|
178
|
-
customize.js, avatarStore.js 캐릭터
|
|
179
|
+
customize.js, avatarStore.js 캐릭터 고르기 (대상별 12명 중 하나 / 자동 / 올린 그림)
|
|
180
|
+
customCharacters.js 커스텀 캐릭터: PNG 투명 여백 자르기·256px 축소, dataURL 을 localStorage 에 (FE-56)
|
|
179
181
|
floorStylePicker.js 층 🎨 → 인테리어 프리셋 선택 (서버에 저장)
|
|
180
182
|
tooltip.js, texttip.js 캐릭터 툴팁, 잘린 글자 툴팁
|
|
181
183
|
scene/
|
|
@@ -185,7 +187,7 @@ public/
|
|
|
185
187
|
pathfind.js A* (4방향), 벽 edge 와 solid 가구 회피
|
|
186
188
|
actors.js Agent → Actor(위치, 경로, 걷기 애니, 말풍선, 슬롯 점유/해제, 휴식 방 안 배회)
|
|
187
189
|
spots.js 휴식 방 명소 (레이아웃이 채우는 live 객체)
|
|
188
|
-
|
|
190
|
+
characters.js 캐릭터 스프라이트 (Kenney Mini Characters, CC0) 동작 프레임·안전모, 파트 조합('mix:...') 레이어·색 입히기·소품, 커스텀 그림('custom:<id>') 그리기
|
|
189
191
|
render.js 바닥 → 3D AABB 깊이 정렬된 가구/벽/캐릭터 → 방 이름표·말풍선
|
|
190
192
|
sprites.js 가구·벽을 코드로 그리는 함수들 (외부 이미지 없음)
|
|
191
193
|
camera.js 팬·줌·맵 맞춤·클릭 판정, 선택 세션 따라가기
|
|
@@ -234,6 +236,8 @@ public/
|
|
|
234
236
|
- **그림 자산 없음**: 가구·캐릭터를 canvas 코드로 그린다 (sprites.js · avatar.js).
|
|
235
237
|
- **재생 가능한 저널**: 상태를 직접 저장하지 않고 원본 이벤트를 남겨 재생한다. 상태 머신이 바뀌어도 저널은 그대로 쓸 수 있다.
|
|
236
238
|
- **뷰어 설정은 브라우저에**: 별명·외형·언어·테마는 localStorage. 층 인테리어처럼 모두가 같이 봐야 하는 것만 서버에 둔다.
|
|
239
|
+
- **커스텀 캐릭터도 브라우저에 (FE-56)**: 올린 PNG 는 서버로 보내지 않는다. 투명 여백을 자르고 긴 변 256px 로 줄인 dataURL 을 `aiOffice.customCharacters.v1` 에 두고(최대 24장·2.5MB, 실패하면 되돌림), 고른 값은 `custom:<id>` 로 `aiOffice.characters.v1` 에 섞인다. 키를 나눠 그림 저장이 실패해도 고르기 설정은 남는다. '자동' 해시는 Kenney 12명만 쓴다. 정지 그림 한 장이라 동작은 몸 전체의 오프셋·기울기·눌림으로 보이고, 안전모 위치는 스프라이트와 같은 알파 스캔(`scanHead`)으로 찾는다.
|
|
240
|
+
- **파트 조합은 굽고 겹친다 (FE-57)**: Kenney 12명은 뼈대가 같고 모델이 머리·몸통 두 덩어리라, 굽기에서 머리 레이어(몸통은 깊이만 써서 팔이 앞에 오면 머리가 잘린다)와 몸통 레이어를 따로 굽는다. 모두 같은 배율(가장 큰 캐릭터 기준)·같은 앉기 높이라 아무 머리나 아무 몸 위에 겹치면 맞는다. 색 바꾸기는 팔레트(8열×4띠) 칸으로 부위를 고른다: 피부 = 머리와 팔에 함께 쓰인 칸, 머리카락 = 머리의 그다음 넓은 칸(얼굴에 붙은 납작한 눈·입은 뺌), 상의·하의 = 몸통·팔 / 다리(신발 위)에서 가장 넓은 칸. 그 부위를 흰 알베도 × 상대 밝기로 다시 그린 음영을 마스크 시트에 두고, 그릴 때 선형 색공간에서 음영 × 새 색으로 칠한 레이어를 캐시한다(최근 16개). 소품은 원래 안경이 없는 male-f 의 머리 뼈에 붙여 구운 시트 하나를 모든 머리에 얹는다. 값은 문자열 `mix:<머리>.<몸>.<머리카락>.<상의>.<하의>.<소품>` 이라 기존 저장·정규화·대상별 설정을 그대로 탄다.
|
|
237
241
|
- **범위**: Claude Code 훅만 받는다. claude.ai 채팅·클라우드 세션은 훅이 없어 다루지 않는다.
|
|
238
242
|
|
|
239
243
|
## 9. 보안 (BE-21)
|
package/package.json
CHANGED
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
package/public/index.html
CHANGED
|
@@ -40,7 +40,7 @@
|
|
|
40
40
|
<div class="settings-row"><span class="settings-label">생동감</span><button id="ambient-btn" class="tool" title="쉬는 캐릭터가 휴게실·카페·테라스 안을 옮겨 다니거나 산책하는 자율 행동">✨ 꺼짐</button></div>
|
|
41
41
|
<div class="settings-row"><span class="settings-label">테마 <kbd>T</kbd></span><button id="theme-btn" class="tool" title="다크 모드 전환">🌙 라이트</button></div>
|
|
42
42
|
<div class="settings-row"><span class="settings-label">데모 <kbd>D</kbd></span><button id="demo-btn" class="tool" title="가짜 세션 4개로 사무실을 북적이게 (실제 세션과 별도 층, '(데모)' 표시)">🎬 꺼짐</button></div>
|
|
43
|
-
<div class="settings-row"><span class="settings-label">캐릭터 외형</span><button id="customize-btn" class="tool" title="나 / Claude / 서브에이전트 캐릭터
|
|
43
|
+
<div class="settings-row"><span class="settings-label">캐릭터 외형</span><button id="customize-btn" class="tool" title="나 / Claude / 서브에이전트 캐릭터 고르기 — 12명 중 하나 또는 머리·몸·색·소품 조합, 세션마다 자동 또는 직접 올린 PNG, 지금 보는 세션의 캐릭터 이름">👤 고르기…</button></div>
|
|
44
44
|
<div class="settings-row"><span class="settings-label">시간대 조명</span><select id="daylight-select" class="tool" title="실제 시각에 따라 창문·테라스·실내 빛이 바뀝니다 (아침 06~10 · 낮 10~17 · 저녁 17~20 · 밤 20~06). 미리보기로 시간대를 고정할 수도 있어요"><option value="auto">🕒 자동 (실제 시각)</option><option value="off">끔</option><option value="morning">🌅 아침</option><option value="day">☀️ 낮</option><option value="evening">🌇 저녁</option><option value="night">🌙 밤</option></select></div>
|
|
45
45
|
<div class="settings-row"><span class="settings-label">층 인테리어</span><button id="floor-style-btn" class="tool" title="지금 보는 층의 인테리어 프리셋 (층별 안내의 🎨 와 같음) — 색과 사무실 구조가 바뀝니다">🎨 고르기…</button></div>
|
|
46
46
|
<div class="settings-row"><span class="settings-label">언어</span><select id="lang-select" class="tool" title="화면 언어"><option value="ko">한국어</option><option value="en">English</option></select></div>
|
package/public/js/avatarStore.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
//
|
|
2
|
-
// 키: me | claude | sub | floor:<floorId>
|
|
3
|
-
import { DEFAULTS,
|
|
1
|
+
// 캐릭터 고르기 저장소. 뷰어 설정이므로 localStorage 에 둔다.
|
|
2
|
+
// 키: me | claude | sub | floor:<floorId> 값: 캐릭터 이름(characters.js 의 CHARACTERS), 'mix:...'(파트 조합), 'custom:<id>'(올린 그림) 또는 'auto'(세션마다 다르게)
|
|
3
|
+
import { DEFAULTS, isCustomCharacter, normalizeCharacter, resolveCharacter } from './scene/characters.js';
|
|
4
|
+
import { createCustomCharacters } from './customCharacters.js';
|
|
4
5
|
|
|
5
|
-
const KEY = 'aiOffice.
|
|
6
|
+
const KEY = 'aiOffice.characters.v1';
|
|
6
7
|
|
|
7
8
|
function load() {
|
|
8
9
|
try {
|
|
@@ -13,35 +14,37 @@ function load() {
|
|
|
13
14
|
} catch { return {}; }
|
|
14
15
|
}
|
|
15
16
|
|
|
16
|
-
export function createAvatarStore() {
|
|
17
|
+
export function createAvatarStore(customs = createCustomCharacters()) {
|
|
17
18
|
let data = load();
|
|
18
19
|
const listeners = new Set();
|
|
19
|
-
const
|
|
20
|
+
customs.subscribe(() => { for (const fn of listeners) fn(); });
|
|
20
21
|
|
|
21
22
|
function save() {
|
|
22
23
|
try { localStorage.setItem(KEY, JSON.stringify(data)); } catch {}
|
|
23
|
-
cache.clear();
|
|
24
24
|
for (const fn of listeners) fn();
|
|
25
25
|
}
|
|
26
26
|
|
|
27
27
|
function baseFor(key) { return key === 'me' ? DEFAULTS.me : key === 'sub' ? DEFAULTS.sub : DEFAULTS.claude; }
|
|
28
28
|
|
|
29
|
-
/** 저장된 값이 없으면
|
|
29
|
+
/** 저장된 값이 없으면 기본값. 지운 커스텀 그림을 가리키면 기본값. 나는 'auto' 가 없다 */
|
|
30
30
|
function get(key) {
|
|
31
|
-
|
|
32
|
-
|
|
31
|
+
let v = normalizeCharacter(data[key], baseFor(key));
|
|
32
|
+
if (isCustomCharacter(v) && !customs.has(v)) v = baseFor(key);
|
|
33
|
+
return key === 'me' && v === 'auto' ? DEFAULTS.me : v;
|
|
33
34
|
}
|
|
34
|
-
function has(key) { return
|
|
35
|
-
function set(key,
|
|
35
|
+
function has(key) { return key in data; }
|
|
36
|
+
function set(key, value) { data[key] = normalizeCharacter(value, baseFor(key)); save(); }
|
|
36
37
|
function reset(key) { delete data[key]; save(); }
|
|
37
38
|
|
|
38
|
-
/** 캐릭터에 쓸
|
|
39
|
+
/** 캐릭터에 쓸 이름: 층별 Claude 설정 > Claude 기본. 'auto' 는 캐릭터 키 해시로 */
|
|
39
40
|
function forActor(a, floorId) {
|
|
40
41
|
if (a.user) return get('me');
|
|
41
|
-
|
|
42
|
-
if (
|
|
43
|
-
|
|
42
|
+
let v;
|
|
43
|
+
if (a.kind === 'sub') v = get('sub');
|
|
44
|
+
else if (floorId && has(`floor:${floorId}`)) v = get(`floor:${floorId}`);
|
|
45
|
+
else v = get('claude');
|
|
46
|
+
return resolveCharacter(v, a.key);
|
|
44
47
|
}
|
|
45
48
|
|
|
46
|
-
return { get, has, set, reset, forActor, baseFor, subscribe: (fn) => { listeners.add(fn); return () => listeners.delete(fn); } };
|
|
49
|
+
return { get, has, set, reset, forActor, baseFor, customs, subscribe: (fn) => { listeners.add(fn); return () => listeners.delete(fn); } };
|
|
47
50
|
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
// 커스텀 캐릭터 (FE-56): 사용자가 올린 배경 없는 그림. 뷰어 설정이므로 localStorage 에 dataURL 로 둔다.
|
|
2
|
+
// 키: aiOffice.customCharacters.v1 = [{ id, name, src, w, h, at }] 캐릭터 값은 'custom:<id>' (characters.js)
|
|
3
|
+
// 고른 캐릭터(aiOffice.characters.v1)와 키를 나눠, 그림이 커서 저장에 실패해도 고르기 설정은 남는다.
|
|
4
|
+
import { setCustomCharacters } from './scene/characters.js';
|
|
5
|
+
|
|
6
|
+
const KEY = 'aiOffice.customCharacters.v1';
|
|
7
|
+
export const LIMITS = {
|
|
8
|
+
fileBytes: 10 * 1024 * 1024, // 올리는 파일
|
|
9
|
+
pixels: 8192, // 원본 한 변
|
|
10
|
+
side: 256, // 저장할 그림의 긴 변
|
|
11
|
+
srcChars: 400 * 1024, // 한 장의 dataURL 길이
|
|
12
|
+
totalChars: 2.5 * 1024 * 1024, // 전부 합친 길이 (localStorage 는 보통 5MB 안팎)
|
|
13
|
+
count: 24,
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
/** 오류 코드 → 안내 문구 (고르기 창에 그대로 보인다) */
|
|
17
|
+
export const MESSAGES = {
|
|
18
|
+
'not-image': '이미지 파일이 아닙니다 — 배경이 투명한 PNG 를 올려 주세요',
|
|
19
|
+
'too-big': '파일이 너무 큽니다 — 10MB 이하로 올려 주세요',
|
|
20
|
+
'too-large': '그림이 너무 큽니다 — 한 변 8192px 이하로 올려 주세요',
|
|
21
|
+
'decode': '이미지를 읽지 못했습니다 — 다른 파일로 해 보세요',
|
|
22
|
+
'empty': '그림이 전부 투명합니다',
|
|
23
|
+
'too-many': '커스텀 캐릭터가 너무 많습니다 — 안 쓰는 것을 지워 주세요',
|
|
24
|
+
'quota': '저장 공간이 부족합니다 — 안 쓰는 커스텀 캐릭터를 지워 주세요',
|
|
25
|
+
'opaque': '배경이 투명하지 않은 그림이라 사각형 그대로 보입니다',
|
|
26
|
+
'added': '추가했습니다 — 저장을 누르면 이 대상에 적용됩니다',
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
function load() {
|
|
30
|
+
try {
|
|
31
|
+
const list = JSON.parse(localStorage.getItem(KEY) || '[]');
|
|
32
|
+
return Array.isArray(list) ? list.filter((c) => c && /^[a-z0-9]{1,32}$/.test(c.id) && typeof c.src === 'string' && c.src.startsWith('data:image/')) : [];
|
|
33
|
+
} catch { return []; }
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const sizeOf = (list) => list.reduce((n, c) => n + c.src.length, 0);
|
|
37
|
+
const newId = () => (Date.now().toString(36) + Math.random().toString(36).slice(2, 6)).slice(-10);
|
|
38
|
+
|
|
39
|
+
export function createCustomCharacters() {
|
|
40
|
+
let list = load();
|
|
41
|
+
const listeners = new Set();
|
|
42
|
+
setCustomCharacters(list);
|
|
43
|
+
|
|
44
|
+
function changed() { setCustomCharacters(list); for (const fn of listeners) fn(); }
|
|
45
|
+
/** 저장. 공간이 모자라면(QuotaExceeded) 이전 목록으로 되돌리고 'quota' */
|
|
46
|
+
function save(next) {
|
|
47
|
+
try { localStorage.setItem(KEY, JSON.stringify(next)); } catch { return 'quota'; }
|
|
48
|
+
list = next; changed();
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** 다듬은 그림 { src, w, h, name } 을 더한다 → { value: 'custom:<id>' } 또는 { error } */
|
|
53
|
+
function add(img) {
|
|
54
|
+
if (!img || typeof img.src !== 'string' || !img.src.startsWith('data:image/')) return { error: 'decode' };
|
|
55
|
+
if (list.length >= LIMITS.count) return { error: 'too-many' };
|
|
56
|
+
if (img.src.length > LIMITS.srcChars || sizeOf(list) + img.src.length > LIMITS.totalChars) return { error: 'quota' };
|
|
57
|
+
const c = { id: newId(), name: String(img.name || '').slice(0, 40), src: img.src, w: img.w | 0, h: img.h | 0, at: Date.now() };
|
|
58
|
+
const error = save([...list, c]);
|
|
59
|
+
return error ? { error } : { value: `custom:${c.id}` };
|
|
60
|
+
}
|
|
61
|
+
function remove(value) {
|
|
62
|
+
const id = String(value).replace(/^custom:/, '');
|
|
63
|
+
if (!list.some((c) => c.id === id)) return;
|
|
64
|
+
save(list.filter((c) => c.id !== id));
|
|
65
|
+
}
|
|
66
|
+
const has = (value) => list.some((c) => `custom:${c.id}` === value);
|
|
67
|
+
|
|
68
|
+
return {
|
|
69
|
+
has, add, remove,
|
|
70
|
+
list: () => list.map((c) => ({ ...c, value: `custom:${c.id}` })),
|
|
71
|
+
bytes: () => sizeOf(list),
|
|
72
|
+
subscribe: (fn) => { listeners.add(fn); return () => listeners.delete(fn); },
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// ---------- 그림 다듬기 ----------
|
|
77
|
+
/** 알파가 min 이상인 픽셀의 경계 상자. 전부 투명하면 null. opaque: 투명한 픽셀이 하나도 없다 */
|
|
78
|
+
export function alphaBounds(data, w, h, min = 16) {
|
|
79
|
+
let x0 = w, y0 = h, x1 = -1, y1 = -1, clear = false;
|
|
80
|
+
for (let y = 0; y < h; y++) {
|
|
81
|
+
for (let x = 0; x < w; x++) {
|
|
82
|
+
const a = data[(y * w + x) * 4 + 3];
|
|
83
|
+
if (a < 250) clear = true;
|
|
84
|
+
if (a < min) continue;
|
|
85
|
+
if (x < x0) x0 = x;
|
|
86
|
+
if (x > x1) x1 = x;
|
|
87
|
+
if (y < y0) y0 = y;
|
|
88
|
+
if (y > y1) y1 = y;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
if (x1 < 0) return null;
|
|
92
|
+
return { x: x0, y: y0, w: x1 - x0 + 1, h: y1 - y0 + 1, opaque: !clear };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** 긴 변이 side 를 넘으면 줄인 크기 */
|
|
96
|
+
export function fitSize(w, h, side = LIMITS.side) {
|
|
97
|
+
const k = Math.min(1, side / Math.max(w, h));
|
|
98
|
+
return { w: Math.max(1, Math.round(w * k)), h: Math.max(1, Math.round(h * k)) };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* 파일 → { src(dataURL), w, h, name, opaque } 또는 { error }. 브라우저에서만.
|
|
103
|
+
* 투명 여백을 잘라내고 긴 변을 LIMITS.side 로 줄인다. PNG 가 크면 WebP 와 비교해 작은 쪽
|
|
104
|
+
*/
|
|
105
|
+
export async function prepareImage(file) {
|
|
106
|
+
if (!file || !/^image\//.test(file.type || '')) return { error: 'not-image' };
|
|
107
|
+
if (file.size > LIMITS.fileBytes) return { error: 'too-big' };
|
|
108
|
+
let bmp;
|
|
109
|
+
try { bmp = await createImageBitmap(file); } catch { return { error: 'decode' }; }
|
|
110
|
+
try {
|
|
111
|
+
const { width: W, height: H } = bmp;
|
|
112
|
+
if (!W || !H) return { error: 'decode' };
|
|
113
|
+
if (W > LIMITS.pixels || H > LIMITS.pixels) return { error: 'too-large' };
|
|
114
|
+
const src = document.createElement('canvas');
|
|
115
|
+
src.width = W; src.height = H;
|
|
116
|
+
const sx = src.getContext('2d', { willReadFrequently: true });
|
|
117
|
+
sx.drawImage(bmp, 0, 0);
|
|
118
|
+
const box = alphaBounds(sx.getImageData(0, 0, W, H).data, W, H);
|
|
119
|
+
if (!box) return { error: 'empty' };
|
|
120
|
+
const out = document.createElement('canvas');
|
|
121
|
+
const size = fitSize(box.w, box.h);
|
|
122
|
+
out.width = size.w; out.height = size.h;
|
|
123
|
+
const ox = out.getContext('2d');
|
|
124
|
+
ox.imageSmoothingEnabled = true; ox.imageSmoothingQuality = 'high';
|
|
125
|
+
ox.drawImage(src, box.x, box.y, box.w, box.h, 0, 0, size.w, size.h);
|
|
126
|
+
let url = out.toDataURL('image/png');
|
|
127
|
+
if (url.length > 120 * 1024) {
|
|
128
|
+
const webp = out.toDataURL('image/webp', 0.92);
|
|
129
|
+
if (webp.startsWith('data:image/webp') && webp.length < url.length) url = webp;
|
|
130
|
+
}
|
|
131
|
+
return { src: url, w: size.w, h: size.h, name: String(file.name || '').replace(/\.[^.]+$/, ''), opaque: box.opaque };
|
|
132
|
+
} finally { bmp.close?.(); }
|
|
133
|
+
}
|