Add sunrise Threads automation and improve capture and character interactions
This commit is contained in:
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"accessToken": "",
|
||||||
|
"siteUrl": "https://sunrise.horoli.kr",
|
||||||
|
"stateDir": "data/threads",
|
||||||
|
"imageDir": "dist/threads-captures",
|
||||||
|
"imageBaseUrl": "https://sunrise.horoli.kr/threads-captures/"
|
||||||
|
}
|
||||||
@@ -18,4 +18,6 @@ __pycache__/
|
|||||||
|
|
||||||
# Environment secrets (keep the documented template)
|
# Environment secrets (keep the documented template)
|
||||||
.env.*
|
.env.*
|
||||||
|
!/.env.threads.sample.json
|
||||||
/.config.json
|
/.config.json
|
||||||
|
/data/threads/
|
||||||
|
|||||||
@@ -4,6 +4,8 @@
|
|||||||
|
|
||||||
# 해보러가기
|
# 해보러가기
|
||||||
|
|
||||||
|
매일 일출 시각에 화면을 촬영하고 Meta Threads에 게시하는 Linux 작업은 [자동 게시 설정 가이드](docs/threads-automation.md)를 참고하세요.
|
||||||
|
|
||||||
## 현재 과천 건물: VWorld 단독 (2026-10-30)
|
## 현재 과천 건물: VWorld 단독 (2026-10-30)
|
||||||
|
|
||||||
OSM 건물의 외곽선·높이·층수와 OSM 대조 기반 선별을 제거했다. VWorld 원본에서 독립적으로 재생성한 **6,743개**를 표시한다(높이 자료 1,756개 / 층수 환산 953개 / 임시 높이 4,034개). VWorld 내부 겹침 183개를 제외했다. 아래 OSM·VWorld 통합 설명은 이전 작업 기록이며 현재 구성에 적용되지 않는다. 해안선·정상 구조물 위치의 OSM 사용은 유지한다.
|
OSM 건물의 외곽선·높이·층수와 OSM 대조 기반 선별을 제거했다. VWorld 원본에서 독립적으로 재생성한 **6,743개**를 표시한다(높이 자료 1,756개 / 층수 환산 953개 / 임시 높이 4,034개). VWorld 내부 겹침 183개를 제외했다. 아래 OSM·VWorld 통합 설명은 이전 작업 기록이며 현재 구성에 적용되지 않는다. 해안선·정상 구조물 위치의 OSM 사용은 유지한다.
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Capture sunrise and publish to Threads
|
||||||
|
Wants=network-online.target
|
||||||
|
After=network-online.target
|
||||||
|
StartLimitIntervalSec=1h
|
||||||
|
StartLimitBurst=5
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=oneshot
|
||||||
|
User=sunrise
|
||||||
|
WorkingDirectory=/opt/sunrise
|
||||||
|
ExecStart=/usr/bin/node /opt/sunrise/server/threads/run.ts
|
||||||
|
TimeoutStartSec=8h
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=60s
|
||||||
|
UMask=0022
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Prepare daily sunrise capture at 03:00 Korea time
|
||||||
|
|
||||||
|
[Timer]
|
||||||
|
OnCalendar=*-*-* 03:00:00 Asia/Seoul
|
||||||
|
Persistent=true
|
||||||
|
AccuracySec=1s
|
||||||
|
Unit=sunrise-threads.service
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=timers.target
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
# 매일 일출 촬영 및 Threads 게시
|
||||||
|
|
||||||
|
## 동작
|
||||||
|
|
||||||
|
Linux systemd 타이머가 매일 **한국 시간 03:00**에 작업을 시작합니다. 기존 `calculateSun` / SunCalc 2.x로 당일 관악산 일출을 계산하고, 5분 전에 Chromium을 실행합니다. 배포 페이지의 실제 관측 위치·고도로 계산한 일출 시각까지 기다린 뒤, UI 없는 **1080×1728 세로 JPEG 두 장**을 같은 시각의 장면에서 촬영하고 **한 개의 Threads 캐러셀 게시물**로 올립니다. 사진은 실제 풍경이 아니라 시뮬레이션임을 게시 문구에 표시합니다. 날씨는 페이지의 기존 기상자료를 사용합니다.
|
||||||
|
|
||||||
|
사진 순서는 ① 일출 방향을 바라보는 캐릭터 뒷모습과 풍경 ② 캐릭터 얼굴과 하늘입니다. 캐릭터는 일출을 향한 자세를 유지하고 두 번째 카메라만 방위각 +200°, 위쪽 20°로 회전합니다. 기존 3인칭 카메라의 지형 충돌 보정을 사용합니다. 구도와 해상도는 `src/constants/threads.ts`의 `angles`, `viewport`에서 조정합니다. 두 사진 모두 준비된 뒤 캐러셀을 만들며, 한 장이라도 실패하면 일부만 게시하지 않습니다.
|
||||||
|
|
||||||
|
이전 단일 사진 버전의 `.capture.json` 기록은 재사용하지 않습니다. 해당 기록이 남아 있다면 보관 위치로 옮긴 뒤 다음 일출부터 실행하세요. 이미 게시했거나 게시 결과가 불명확한 날짜의 `.json` 게시 기록은 유지해야 중복을 방지할 수 있습니다.
|
||||||
|
|
||||||
|
SunCalc의 일출은 관측 고도를 반영한 천문학적 일출입니다. 주변 산 능선 뒤에서 태양이 실제로 드러나는 시각과 다를 수 있습니다. 서버 시계 동기화(NTP)를 켜 두세요. 캡처 후 업로드·Meta 처리 때문에 게시 시각은 일출보다 늦습니다.
|
||||||
|
|
||||||
|
변경된 프런트엔드를 먼저 배포해야 합니다. 작업은 `?mode=solo&site=gwanaksan&capture=threads`로 열며, 해당 모드에서만 브라우저 내 촬영 인터페이스가 노출됩니다. 인증 토큰은 브라우저로 전달하지 않습니다.
|
||||||
|
|
||||||
|
## 1. 서버 설치
|
||||||
|
|
||||||
|
아래는 프로젝트 `/opt/sunrise`, 서비스 사용자 `sunrise`, Node 실행 파일 `/usr/bin/node` 예시입니다. 실제 경로·기존 서비스 사용자에 맞게 수정하세요. Node.js는 프로젝트 요구사항인 22.18 이상을 사용합니다.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /opt/sunrise
|
||||||
|
npm ci
|
||||||
|
npm run prepare:buildings
|
||||||
|
npm run prepare:summit
|
||||||
|
npm run build
|
||||||
|
# OS 라이브러리 설치는 sudo 권한이 필요합니다.
|
||||||
|
sudo npx playwright install-deps chromium
|
||||||
|
# 브라우저는 작업을 실행할 사용자로 설치해야 합니다.
|
||||||
|
sudo -u sunrise npx playwright install chromium
|
||||||
|
cp -n .env.threads.sample.json .env.threads.json
|
||||||
|
chmod 600 .env.threads.json
|
||||||
|
```
|
||||||
|
|
||||||
|
`.env.threads.json`와 프로젝트의 이미지/상태 저장 경로는 `sunrise` 사용자가 읽고 쓸 수 있어야 합니다. root로 Chromium을 실행하지 않습니다. Linux에서 Chromium sandbox를 지원해야 하며, 지원하지 않는 환경은 OS 사용자 namespace/sandbox 설정을 점검하세요.
|
||||||
|
|
||||||
|
설정 파일은 프로젝트 루트의 `.env.threads.json`을 읽습니다. 샘플을 복사한 뒤 `accessToken`에 발급받은 토큰을 넣으세요. 상대 디렉터리 경로는 실행 작업 디렉터리를 기준으로 하므로 프로젝트 루트에서 실행합니다. 실제 설정 파일은 Git에서 제외되고, 토큰이 비어 있는 샘플만 버전 관리됩니다. 기존 `.env.threads` 환경변수 파일은 더 이상 읽지 않습니다.
|
||||||
|
|
||||||
|
## 2. Meta 계정 연결
|
||||||
|
|
||||||
|
Meta 개발자 콘솔에서 Threads 용도의 앱을 만들고, 자신의 계정에 `threads_basic`, `threads_content_publish` 권한을 부여합니다. 개발 모드에서는 앱의 테스터/역할 계정 설정 및 초대 수락을 확인하세요. 발급한 토큰이 장기 토큰인지 확인하고(단기 토큰이면 장기 토큰으로 교환), `.env.threads.json`의 `accessToken`에 넣습니다. 초기 토큰은 새로 발급한 것을 사용하세요.
|
||||||
|
|
||||||
|
- [Meta 공식 Threads API / 계정 연결](https://www.postman.com/meta/threads/collection/dht3nzz/threads-api)
|
||||||
|
- [토큰 교환·갱신 및 이미지 게시 API](https://www.postman.com/meta/threads/documentation/dht3nzz/threads-api)
|
||||||
|
|
||||||
|
작업은 토큰을 비공개 `data/threads/token.json`에 저장하고 7일 간격으로 갱신합니다. 이후에는 저장된 토큰이 JSON 설정의 accessToken보다 우선합니다. 재인증 시 서비스를 중지하고 `token.json`을 별도 보관/삭제한 다음 JSON 설정의 accessToken에 새 long-lived 토큰을 설정하세요. 토큰 철회·만료나 권한 변경은 재인증이 필요하며 자동으로 해결할 수 없습니다. 토큰 파일과 상태 디렉터리는 정적 웹 루트에 두지 마세요.
|
||||||
|
|
||||||
|
## 3. 촬영 검증 (게시하지 않음)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run threads:preview
|
||||||
|
```
|
||||||
|
|
||||||
|
현재 시각의 장면을 즉시 촬영하고 파일 경로를 출력합니다. 토큰 없이 실행할 수 있으며 Threads API 호출이나 게시 기록 생성은 없습니다. 다음 일출에 촬영만 검증하려면:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run threads:run -- --dry-run
|
||||||
|
```
|
||||||
|
|
||||||
|
`--now`는 `--dry-run`과 함께만 허용됩니다. 테스트 때문에 실제 계정에 게시하지 않습니다.
|
||||||
|
|
||||||
|
## 4. 이미지 공개 URL
|
||||||
|
|
||||||
|
기본 저장 위치는 `dist/threads-captures/`, 공개 URL은 `https://sunrise.horoli.kr/threads-captures/`입니다. 기존 Node 정적 파일 서버가 이미지를 제공합니다. 저장된 JPEG의 공개 URL을 외부에서 열어 확인하세요. 게시 직전에 작업도 실제 다운로드한 바이트가 저장된 이미지와 같은지 확인합니다. Meta 서버가 로그인 없이 이미지를 읽을 수 있어야 합니다.
|
||||||
|
|
||||||
|
`npm run build`는 dist를 다시 생성합니다. 운영에서는 **배포와 독립적인 이미지 디렉터리**를 권장합니다. 예를 들어 `"imageDir": "/var/lib/sunrise/captures"`로 지정하고 Nginx에서 `/threads-captures/`를 해당 디렉터리에 연결하세요. Nginx 사용자에게 디렉터리 탐색·파일 읽기 권한을 부여하세요. `imageBaseUrl`도 실제 URL과 일치해야 합니다. 이미지에는 토큰이 포함되지 않습니다. 이미지 파일은 자동 삭제하지 않으므로 저장 용량을 관리하세요.
|
||||||
|
|
||||||
|
## 5. 자동 실행 활성화
|
||||||
|
|
||||||
|
`deploy/sunrise-threads.service`의 사용자, 작업 디렉터리, Node 경로를 수정한 뒤:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo cp deploy/sunrise-threads.service deploy/sunrise-threads.timer /etc/systemd/system/
|
||||||
|
sudo systemctl daemon-reload
|
||||||
|
sudo systemctl enable --now sunrise-threads.timer
|
||||||
|
systemctl list-timers sunrise-threads.timer
|
||||||
|
journalctl -u sunrise-threads.service -n 100
|
||||||
|
```
|
||||||
|
|
||||||
|
타이머는 03:00에 준비 작업을 시작하고 프로세스가 계산된 일출까지 기다립니다. 서버의 기본 시간대와 관계없이 `Asia/Seoul` 기준입니다. 일출을 놓친 뒤 서버가 켜지면 그날 촬영은 건너뜁니다. 촬영 허용 지연은 최대 30초이며, 로딩 지연이나 절전 등으로 이를 초과하면 게시하지 않습니다. 장애 시 journal에 오류가 남습니다. 별도 알림 채널은 연결하지 않았습니다.
|
||||||
|
|
||||||
|
## 게시 기록과 재시도
|
||||||
|
|
||||||
|
`data/threads/YYYY-MM-DD.json`에 게시 기록을 저장합니다. Meta에 실제 게시 요청을 보내기 **직전** 파일을 배타적으로 생성해 동시 실행도 중복 게시하지 않게 합니다. 성공 시 `published`와 게시 ID가 저장됩니다.
|
||||||
|
|
||||||
|
`publishing` 상태가 남았다면 요청이 성공했지만 응답이 유실됐을 수 있습니다. 이때 자동으로 재게시하지 않습니다. 계정과 기록의 컨테이너 ID를 확인한 뒤 수동으로 처리하세요. 기존 기록을 무조건 지우면 중복 게시될 수 있습니다.
|
||||||
|
|
||||||
|
촬영 성공 시 별도 `YYYY-MM-DD.capture.json`을 저장합니다. 토큰 갱신·공개 URL 확인·컨테이너 준비가 실패하면 systemd가 60초 뒤 재시도합니다(1시간 내 최대 5회 시작). 같은 날 재실행은 저장된 일출 사진을 재사용하므로 게시 준비 중 오류가 나도 촬영을 잃지 않습니다. 촬영 자체를 놓쳤다면 그날은 건너뜁니다. 다음 날짜에 전날 사진을 자동 게시하지 않습니다.
|
||||||
|
|
||||||
|
정지: `sudo systemctl disable --now sunrise-threads.timer` (이미 실행 중인 작업도 중지하려면 `sudo systemctl stop sunrise-threads.service`).
|
||||||
@@ -51,6 +51,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="toolbar-end">
|
<div class="toolbar-end">
|
||||||
|
<button type="button" id="screenshot-open" data-panel="screenshot" aria-label="촬영" title="화면 촬영" aria-haspopup="dialog" aria-controls="ui-dialog" disabled><svg class="icon" aria-hidden="true" viewBox="0 0 24 24"><path d="M3 6h4l2-3h6l2 3h4v15H3Z"/><circle cx="12" cy="13" r="4"/></svg></button>
|
||||||
<button type="button" data-panel="menu" aria-label="메뉴" aria-haspopup="dialog" aria-controls="ui-dialog"><svg class="icon" aria-hidden="true"><use href="#icon-menu"/></svg></button>
|
<button type="button" data-panel="menu" aria-label="메뉴" aria-haspopup="dialog" aria-controls="ui-dialog"><svg class="icon" aria-hidden="true"><use href="#icon-menu"/></svg></button>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
@@ -101,6 +102,13 @@
|
|||||||
<dialog id="ui-dialog" aria-labelledby="dialog-title">
|
<dialog id="ui-dialog" aria-labelledby="dialog-title">
|
||||||
<header class="dialog-header"><button type="button" id="dialog-back" aria-label="뒤로" hidden>←</button><h1 id="dialog-title">메뉴</h1><button type="button" data-close aria-label="닫기" autofocus>×</button></header>
|
<header class="dialog-header"><button type="button" id="dialog-back" aria-label="뒤로" hidden>←</button><h1 id="dialog-title">메뉴</h1><button type="button" data-close aria-label="닫기" autofocus>×</button></header>
|
||||||
<div class="dialog-body">
|
<div class="dialog-body">
|
||||||
|
<section data-dialog-panel="screenshot" hidden>
|
||||||
|
<p class="muted">현재 보고 있는 장면을 PNG 이미지로 저장합니다.</p>
|
||||||
|
<div class="menu-list">
|
||||||
|
<button type="button" data-screenshot="ui">UI 포함 · 화면 그대로</button>
|
||||||
|
<button type="button" data-screenshot="scene">UI 제외 · 풍경만</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
<section data-dialog-panel="menu" hidden>
|
<section data-dialog-panel="menu" hidden>
|
||||||
<p id="menu-current-mode" class="muted menu-current-mode">현재 함께 보기 중</p>
|
<p id="menu-current-mode" class="muted menu-current-mode">현재 함께 보기 중</p>
|
||||||
<nav class="menu-list menu-primary" aria-label="주요 메뉴">
|
<nav class="menu-list menu-primary" aria-label="주요 메뉴">
|
||||||
@@ -149,6 +157,7 @@
|
|||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</dialog>
|
</dialog>
|
||||||
|
<p id="screenshot-status" role="status" aria-live="polite" hidden></p>
|
||||||
<p id="render-error" role="alert" hidden></p>
|
<p id="render-error" role="alert" hidden></p>
|
||||||
<script type="module" src="/src/entry.ts"></script>
|
<script type="module" src="/src/entry.ts"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
Generated
+35
@@ -8,7 +8,9 @@
|
|||||||
"name": "gwanaksan-sunrise",
|
"name": "gwanaksan-sunrise",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"html-to-image": "^1.11.13",
|
||||||
"mongodb": "^7.7.0",
|
"mongodb": "^7.7.0",
|
||||||
|
"playwright": "^1.64.1",
|
||||||
"socket.io": "^4.8.4",
|
"socket.io": "^4.8.4",
|
||||||
"socket.io-client": "^4.8.4",
|
"socket.io-client": "^4.8.4",
|
||||||
"suncalc": "2.0.1",
|
"suncalc": "2.0.1",
|
||||||
@@ -1179,6 +1181,12 @@
|
|||||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/html-to-image": {
|
||||||
|
"version": "1.11.13",
|
||||||
|
"resolved": "https://registry.npmjs.org/html-to-image/-/html-to-image-1.11.13.tgz",
|
||||||
|
"integrity": "sha512-cuOPoI7WApyhBElTTb9oqsawRvZ0rHhaHwghRLlTuffoD1B2aDemlCruLeZrUIIdvG7gs9xeELEPm6PhuASqrg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/memory-pager": {
|
"node_modules/memory-pager": {
|
||||||
"version": "1.5.0",
|
"version": "1.5.0",
|
||||||
"resolved": "https://registry.npmjs.org/memory-pager/-/memory-pager-1.5.0.tgz",
|
"resolved": "https://registry.npmjs.org/memory-pager/-/memory-pager-1.5.0.tgz",
|
||||||
@@ -1335,6 +1343,33 @@
|
|||||||
"url": "https://github.com/sponsors/jonschlinkert"
|
"url": "https://github.com/sponsors/jonschlinkert"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/playwright": {
|
||||||
|
"version": "1.64.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.64.1.tgz",
|
||||||
|
"integrity": "sha512-HCrVhHr5QQuwYoswEUGTb7gGp522yflbv/ST8nF4sFWGuLjQCt17Q/EubpGGSNKYn1tsTCPZxqHhHCfVURu7UA==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"playwright-core": "1.64.1"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"playwright": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/playwright-core": {
|
||||||
|
"version": "1.64.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.64.1.tgz",
|
||||||
|
"integrity": "sha512-BC4s8y6SEVA6Q1rSxZTto8hI1KUI3xXlHGZgvGqP4IqPdI/a6FX9H29IDNz7vKo9l21b6LfYosScPL6M3xxyDQ==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"bin": {
|
||||||
|
"playwright-core": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/postcss": {
|
"node_modules/postcss": {
|
||||||
"version": "8.5.32",
|
"version": "8.5.32",
|
||||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.32.tgz",
|
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.32.tgz",
|
||||||
|
|||||||
@@ -7,6 +7,8 @@
|
|||||||
"node": ">=22.18.0"
|
"node": ">=22.18.0"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
"threads:run": "node server/threads/run.ts",
|
||||||
|
"threads:preview": "node server/threads/run.ts --dry-run --now",
|
||||||
"dev": "vite --host 127.0.0.1",
|
"dev": "vite --host 127.0.0.1",
|
||||||
"prepare:buildings": "node scripts/prepare-building-assets.ts",
|
"prepare:buildings": "node scripts/prepare-building-assets.ts",
|
||||||
"check:buildings": "node scripts/prepare-building-assets.ts --check",
|
"check:buildings": "node scripts/prepare-building-assets.ts --check",
|
||||||
@@ -25,7 +27,9 @@
|
|||||||
"verify:solar": "node tests/solar-report.ts"
|
"verify:solar": "node tests/solar-report.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"html-to-image": "^1.11.13",
|
||||||
"mongodb": "^7.7.0",
|
"mongodb": "^7.7.0",
|
||||||
|
"playwright": "^1.64.1",
|
||||||
"socket.io": "^4.8.4",
|
"socket.io": "^4.8.4",
|
||||||
"socket.io-client": "^4.8.4",
|
"socket.io-client": "^4.8.4",
|
||||||
"suncalc": "2.0.1",
|
"suncalc": "2.0.1",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"version": 1,
|
"version": 1,
|
||||||
"sourceHash": "ec19bec7b7dded0b4ed8e0733dea4ff2a87976869461cde9a99f6608c6b7836b",
|
"sourceHash": "b1af9482e14c57eb27998ef06b39c129d02770cf3eba5037b9983147c64f7dba",
|
||||||
"bufferHash": "d8cf0a36a46572060189e2ca9e9a61e448fb3d0b441abc346b56b7c4603e0d65",
|
"bufferHash": "d8cf0a36a46572060189e2ca9e9a61e448fb3d0b441abc346b56b7c4603e0d65",
|
||||||
"byteLength": 11036560,
|
"byteLength": 11036560,
|
||||||
"terrain": {
|
"terrain": {
|
||||||
|
|||||||
Binary file not shown.
@@ -255,3 +255,31 @@ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|||||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
SOFTWARE.
|
SOFTWARE.
|
||||||
|
|
||||||
|
========================================================================
|
||||||
|
|
||||||
|
html-to-image 1.11.13
|
||||||
|
License: MIT
|
||||||
|
Source: https://github.com/bubkoo/html-to-image
|
||||||
|
|
||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2017-2025 W.Y.
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"version": 1,
|
"version": 1,
|
||||||
"sourceHash": "6f03c38b2007114b94747b0abbcc26c56b408b0353fc3068b19cc17518542ecc",
|
"sourceHash": "407cca469fa95b26fe4f8da3cbc515620903ebb723c1aea08e59c4e2ca591acc",
|
||||||
"bufferHash": "8fa1ab6646cacc15fc1e84ace034a71826a8dc50f9d5dc2c15aab39e290166d6",
|
"bufferHash": "8fa1ab6646cacc15fc1e84ace034a71826a8dc50f9d5dc2c15aab39e290166d6",
|
||||||
"byteLength": 16099324,
|
"byteLength": 16099324,
|
||||||
"gzipBytes": 6369382,
|
"gzipBytes": 6369382,
|
||||||
@@ -8,6 +8,6 @@
|
|||||||
"vertices": 215769,
|
"vertices": 215769,
|
||||||
"triangles": 406610,
|
"triangles": 406610,
|
||||||
"originalAttributeBytes": 63431160,
|
"originalAttributeBytes": 63431160,
|
||||||
"generateMs": 4164.3897,
|
"generateMs": 4067.9287999999997,
|
||||||
"decodeMs": 16.178200000000288
|
"decodeMs": 17.9390999999996
|
||||||
}
|
}
|
||||||
|
|||||||
+18
-11
@@ -1,5 +1,5 @@
|
|||||||
"""Run with Blender MCP. Separate cartoon rain gear for the rounded v10 sheep."""
|
"""Run with Blender MCP. Separate cartoon rain gear for the rounded v10 sheep."""
|
||||||
import bpy, bmesh, math, json, struct
|
import bpy, bmesh, math, json, struct, sys
|
||||||
from mathutils import Vector, Matrix
|
from mathutils import Vector, Matrix
|
||||||
ROOT='D:/project/sunrise'
|
ROOT='D:/project/sunrise'
|
||||||
with bpy.data.libraries.load(ROOT+'/assets/characters/ivory-lamb-v10.blend',link=False) as (src,dst):
|
with bpy.data.libraries.load(ROOT+'/assets/characters/ivory-lamb-v10.blend',link=False) as (src,dst):
|
||||||
@@ -54,27 +54,34 @@ def sphere(name,location,scale,mat,parent):
|
|||||||
|
|
||||||
umbrella=empty('Umbrella')
|
umbrella=empty('Umbrella')
|
||||||
# The grip is in the raised right hoof; the tilted shaft centers the canopy above the head.
|
# The grip is in the raised right hoof; the tilted shaft centers the canopy above the head.
|
||||||
grip=Vector((.58,-.40,.82)); top=Vector((0,-.05,3.03))
|
grip=Vector((.58,-.40,.82)); top=Vector((0,-.05,3.18))
|
||||||
stroke('Umbrella shaft',[grip,top],.022,ink,umbrella)
|
stroke('Umbrella shaft',[grip,top],.022,ink,umbrella)
|
||||||
|
def canopy_point(radius,sector,across):
|
||||||
|
# Taut fabric curves inward between the eight projecting rib tips.
|
||||||
|
scallop=math.sin(math.pi*across)
|
||||||
|
r=1.20*radius*(1-.10*scallop*radius**3)
|
||||||
|
a=(sector+across)*math.pi/4
|
||||||
|
return (r*math.cos(a),-.05+r*math.sin(a),3.18-.65*radius**1.4+.10*scallop*radius**3)
|
||||||
for sector in range(8):
|
for sector in range(8):
|
||||||
vertices=[];faces=[]
|
vertices=[];faces=[]
|
||||||
for row in range(9):
|
for row in range(9):
|
||||||
theta=(math.pi/2)*row/8
|
|
||||||
for col in range(5):
|
for col in range(5):
|
||||||
a=(sector+col/4)*math.pi/4
|
vertices.append(canopy_point(row/8,sector,col/4))
|
||||||
vertices.append((1.28*math.sin(theta)*math.cos(a),-.05+1.28*math.sin(theta)*math.sin(a),2.53+.50*math.cos(theta)))
|
|
||||||
for row in range(8):
|
for row in range(8):
|
||||||
for col in range(4):
|
for col in range(4):
|
||||||
i=row*5+col;faces.append((i,i+1,i+6,i+5))
|
i=row*5+col;faces.append((i,i+1,i+6,i+5))
|
||||||
panel=mesh('Canopy panel',vertices,faces,red if sector%2==0 else cream,umbrella)
|
panel=mesh('Canopy panel',vertices,faces,red,umbrella)
|
||||||
bpy.context.view_layer.objects.active=panel
|
bpy.context.view_layer.objects.active=panel
|
||||||
solid=panel.modifiers.new('Canopy thickness','SOLIDIFY');solid.thickness=.018
|
solid=panel.modifiers.new('Canopy thickness','SOLIDIFY');solid.thickness=.018
|
||||||
bpy.ops.object.modifier_apply(modifier=solid.name)
|
bpy.ops.object.modifier_apply(modifier=solid.name)
|
||||||
a=sector*math.pi/4
|
stroke('Canopy rib',[canopy_point(i/16,sector,0) for i in range(17)],.009,ink,umbrella)
|
||||||
stroke('Canopy rib',[(1.28*math.sin(t)*math.cos(a),-.05+1.28*math.sin(t)*math.sin(a),2.54+.50*math.cos(t)) for t in [i*math.pi/32 for i in range(17)]],.009,ink,umbrella)
|
stroke('Canopy rim',[canopy_point(1,sector,i/12) for i in range(13)],.012,ink,umbrella)
|
||||||
stroke('Canopy rim',[(1.28*math.cos(a),-.05+1.28*math.sin(a),2.53) for a in [i*math.tau/96 for i in range(97)]],.016,ink,umbrella)
|
sphere('Rib tip',canopy_point(1,sector,0),(.025,.025,.025),ink,umbrella)
|
||||||
sphere('Umbrella tip',(0,-.05,3.055),(.04,.04,.07),ink,umbrella)
|
stroke('Umbrella tip',[(0,-.05,3.17),(0,-.05,3.33)],.022,ink,umbrella)
|
||||||
sphere('Grip',grip,(.052,.052,.12),red,umbrella)
|
sphere('Grip',grip,(.052,.052,.12),red,umbrella)
|
||||||
|
stroke('Hook handle',[grip,(grip.x,grip.y,grip.z-.16)]+[
|
||||||
|
(grip.x-.10+.10*math.cos(a),grip.y,grip.z-.16-.10*math.sin(a))
|
||||||
|
for a in [i*math.pi/16 for i in range(17)]],.033,red,umbrella)
|
||||||
|
|
||||||
coat=empty('Raincoat')
|
coat=empty('Raincoat')
|
||||||
for joint_name in ('Body','ArmLeft','ArmRight'):
|
for joint_name in ('Body','ArmLeft','ArmRight'):
|
||||||
@@ -174,6 +181,6 @@ def export(root,path):
|
|||||||
f.write(struct.pack('<III',0x46546C67,2,20+len(encoded)+len(binary)))
|
f.write(struct.pack('<III',0x46546C67,2,20+len(encoded)+len(binary)))
|
||||||
f.write(struct.pack('<II',len(encoded),0x4E4F534A));f.write(encoded);f.write(binary)
|
f.write(struct.pack('<II',len(encoded),0x4E4F534A));f.write(encoded);f.write(binary)
|
||||||
export(umbrella,ROOT+'/public/characters/umbrella.glb')
|
export(umbrella,ROOT+'/public/characters/umbrella.glb')
|
||||||
export(coat,ROOT+'/public/characters/raincoat.glb')
|
if '--umbrella-only' not in sys.argv:export(coat,ROOT+'/public/characters/raincoat.glb')
|
||||||
bpy.data.libraries.write(ROOT+'/assets/characters/rain-gear.blend',{scene})
|
bpy.data.libraries.write(ROOT+'/assets/characters/rain-gear.blend',{scene})
|
||||||
print('Umbrella and articulated raincoat exported')
|
print('Umbrella and articulated raincoat exported')
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { THREADS } from '../../src/constants/threads.ts';
|
||||||
|
|
||||||
|
export interface ThreadsConfig {
|
||||||
|
accessToken: string;
|
||||||
|
siteUrl: string;
|
||||||
|
stateDir: string;
|
||||||
|
imageDir: string;
|
||||||
|
imageBaseUrl: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadThreadsConfig(path: string | URL = new URL('../../.env.threads.json', import.meta.url), optional = false): ThreadsConfig {
|
||||||
|
const defaults: ThreadsConfig = {
|
||||||
|
accessToken: '', siteUrl: THREADS.siteUrl, stateDir: 'data/threads',
|
||||||
|
imageDir: 'dist/threads-captures', imageBaseUrl: `${THREADS.siteUrl}/threads-captures/`,
|
||||||
|
};
|
||||||
|
let contents: string;
|
||||||
|
try { contents = readFileSync(path, 'utf8'); }
|
||||||
|
catch (error) {
|
||||||
|
if (optional && (error as NodeJS.ErrnoException).code === 'ENOENT') return defaults;
|
||||||
|
throw new Error('Cannot read .env.threads.json; copy .env.threads.sample.json and configure it.');
|
||||||
|
}
|
||||||
|
let value: unknown;
|
||||||
|
try { value = JSON.parse(contents); }
|
||||||
|
catch { throw new Error('Invalid JSON in .env.threads.json'); }
|
||||||
|
if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('Invalid .env.threads.json: expected an object');
|
||||||
|
const config = { ...defaults, ...value } as ThreadsConfig;
|
||||||
|
for (const field of Object.keys(defaults) as (keyof ThreadsConfig)[]) {
|
||||||
|
if (typeof config[field] !== 'string' || (field !== 'accessToken' && !config[field].trim())) {
|
||||||
|
throw new Error(`Invalid .env.threads.json field: ${field}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const field of ['siteUrl', 'imageBaseUrl'] as const) {
|
||||||
|
try {
|
||||||
|
if (!['http:', 'https:'].includes(new URL(config[field]).protocol)) throw new Error();
|
||||||
|
} catch { throw new Error(`Invalid .env.threads.json field: ${field}`); }
|
||||||
|
}
|
||||||
|
return config;
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import { setTimeout as delay } from 'node:timers/promises';
|
||||||
|
import { calculateSun } from '../../src/astronomy/sunCalculator.ts';
|
||||||
|
import { observer } from '../../src/config/observer.ts';
|
||||||
|
import { kstFields } from '../../src/ui/time.ts';
|
||||||
|
import { THREADS } from '../../src/constants/threads.ts';
|
||||||
|
|
||||||
|
export function dailySchedule(now: Date) {
|
||||||
|
const sunrise = calculateSun(now, observer).sunrise;
|
||||||
|
if (!sunrise || !Number.isFinite(+sunrise)) throw new Error('No sunrise for this date');
|
||||||
|
return { date: kstFields(now).date, sunrise, prepareAt: new Date(+sunrise - THREADS.prepareMs) };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function threadsRequest(token: string, path: string, params: Record<string, string>, method = 'GET', request: typeof fetch = fetch) {
|
||||||
|
const url = new URL(path, THREADS.apiUrl);
|
||||||
|
if (method === 'GET') url.search = new URLSearchParams(params).toString();
|
||||||
|
const response = await request(url, {
|
||||||
|
method, headers: { Authorization: `Bearer ${token}` },
|
||||||
|
body: method === 'POST' ? new URLSearchParams(params) : undefined,
|
||||||
|
signal: AbortSignal.timeout(THREADS.requestTimeoutMs),
|
||||||
|
});
|
||||||
|
const data = await response.json();
|
||||||
|
// Do not log response bodies: authentication errors may echo credentials.
|
||||||
|
if (!response.ok || data.error) throw new Error(`Threads API failed (HTTP ${response.status}, code ${data.error?.code ?? 'unknown'})`);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function publishImage(token: string, imageUrl: string, text: string,
|
||||||
|
beforePublish: (containerId: string) => Promise<void>, request: typeof fetch = fetch,
|
||||||
|
wait: (ms: number) => Promise<unknown> = delay) {
|
||||||
|
const container = await threadsRequest(token, '/me/threads', {
|
||||||
|
media_type: 'IMAGE', image_url: imageUrl, text,
|
||||||
|
alt_text: '관악산 정상에서 바라본 일출 시각의 3D 시뮬레이션',
|
||||||
|
}, 'POST', request);
|
||||||
|
if (!container.id) throw new Error('Threads returned no container ID');
|
||||||
|
return publishContainer(token, container.id, beforePublish, request, wait);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function publishCarousel(token: string, imageUrls: string[], text: string,
|
||||||
|
beforePublish: (containerId: string) => Promise<void>, request: typeof fetch = fetch,
|
||||||
|
wait: (ms: number) => Promise<unknown> = delay) {
|
||||||
|
if (imageUrls.length !== THREADS.angles.length) throw new Error('Both sunrise angles are required');
|
||||||
|
const children: string[] = [];
|
||||||
|
for (const [index, imageUrl] of imageUrls.entries()) {
|
||||||
|
const child = await threadsRequest(token, '/me/threads', {
|
||||||
|
media_type: 'IMAGE', image_url: imageUrl, is_carousel_item: 'true',
|
||||||
|
alt_text: `${THREADS.angles[index].alt} · 3D 시뮬레이션`,
|
||||||
|
}, 'POST', request);
|
||||||
|
if (!child.id) throw new Error('Threads returned no child container ID');
|
||||||
|
await waitForMedia(token, child.id, request, wait);
|
||||||
|
children.push(child.id);
|
||||||
|
}
|
||||||
|
const container = await threadsRequest(token, '/me/threads', {
|
||||||
|
media_type: 'CAROUSEL', children: children.join(','), text,
|
||||||
|
}, 'POST', request);
|
||||||
|
if (!container.id) throw new Error('Threads returned no carousel ID');
|
||||||
|
return publishContainer(token, container.id, beforePublish, request, wait);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function publishContainer(token: string, id: string, beforePublish: (id: string) => Promise<void>,
|
||||||
|
request: typeof fetch, wait: (ms: number) => Promise<unknown>) {
|
||||||
|
await waitForMedia(token, id, request, wait);
|
||||||
|
await beforePublish(id);
|
||||||
|
const post = await threadsRequest(token, '/me/threads_publish', { creation_id: id }, 'POST', request);
|
||||||
|
if (!post.id) throw new Error('Threads returned no post ID; check account before retrying');
|
||||||
|
return String(post.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForMedia(token: string, id: string, request: typeof fetch, wait: (ms: number) => Promise<unknown>) {
|
||||||
|
for (let i = 0; i < THREADS.mediaPollAttempts; i++) {
|
||||||
|
const media = await threadsRequest(token, `/${id}`, { fields: 'status,error_message' }, 'GET', request);
|
||||||
|
if (media.status === 'FINISHED') return;
|
||||||
|
if (['ERROR', 'EXPIRED', 'PUBLISHED'].includes(media.status)) throw new Error(`Threads container status: ${media.status}`);
|
||||||
|
await wait(THREADS.mediaPollMs);
|
||||||
|
}
|
||||||
|
throw new Error('Threads media processing timed out');
|
||||||
|
}
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
|
||||||
|
import { resolve, join } from 'node:path';
|
||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
import { setTimeout as delay } from 'node:timers/promises';
|
||||||
|
import { chromium } from 'playwright';
|
||||||
|
import { THREADS } from '../../src/constants/threads.ts';
|
||||||
|
import { kstClock, kstFields } from '../../src/ui/time.ts';
|
||||||
|
import { dailySchedule, publishCarousel, threadsRequest } from './publisher.ts';
|
||||||
|
import { loadThreadsConfig } from './config.ts';
|
||||||
|
import type {} from '../../src/ui/threadsCapture.ts';
|
||||||
|
|
||||||
|
const args = process.argv.slice(2);
|
||||||
|
const dryRun = args.includes('--dry-run');
|
||||||
|
const config = loadThreadsConfig(undefined, dryRun);
|
||||||
|
const stateDir = resolve(config.stateDir);
|
||||||
|
const imageDir = resolve(config.imageDir);
|
||||||
|
const siteUrl = new URL(config.siteUrl);
|
||||||
|
const publicUrl = new URL(config.imageBaseUrl);
|
||||||
|
|
||||||
|
async function readJson(path: string) {
|
||||||
|
try { return JSON.parse(await readFile(path, 'utf8')); }
|
||||||
|
catch (error) { if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null; throw error; }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveJson(path: string, data: unknown) {
|
||||||
|
const temporary = `${path}.${randomUUID()}.tmp`;
|
||||||
|
await writeFile(temporary, JSON.stringify(data, null, 2), { mode: 0o600 });
|
||||||
|
await rename(temporary, path);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function accessToken() {
|
||||||
|
const path = join(stateDir, 'token.json');
|
||||||
|
let saved = await readJson(path);
|
||||||
|
if (!saved) {
|
||||||
|
const token = config.accessToken.trim();
|
||||||
|
if (!token) throw new Error('Set accessToken in .env.threads.json to a long-lived Threads token');
|
||||||
|
saved = { token, refreshedAt: Date.now() };
|
||||||
|
await saveJson(path, saved);
|
||||||
|
}
|
||||||
|
if (Date.now() - saved.refreshedAt >= THREADS.tokenRefreshMs) {
|
||||||
|
const refreshed = await threadsRequest(saved.token, '/refresh_access_token', { grant_type: 'th_refresh_token' });
|
||||||
|
if (!refreshed.access_token) throw new Error('Token refresh returned no access token');
|
||||||
|
saved = { token: refreshed.access_token, refreshedAt: Date.now() };
|
||||||
|
await saveJson(path, saved);
|
||||||
|
}
|
||||||
|
return saved.token as string;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitUntil(date: Date) {
|
||||||
|
while (+date > Date.now()) await delay(Math.min(+date - Date.now(), 60_000));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
if (args.some(arg => !['--dry-run', '--now'].includes(arg)) || (args.includes('--now') && !dryRun)) {
|
||||||
|
throw new Error('Usage: run.ts [--dry-run [--now]]. --now is capture-only.');
|
||||||
|
}
|
||||||
|
const schedule = dailySchedule(new Date());
|
||||||
|
const recordPath = join(stateDir, `${schedule.date}.json`);
|
||||||
|
await mkdir(stateDir, { recursive: true, mode: 0o700 });
|
||||||
|
if (!dryRun && await readJson(recordPath)) {
|
||||||
|
console.log(`${schedule.date}: publication already recorded; inspect the record if status is publishing`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const immediate = dryRun && args.includes('--now');
|
||||||
|
const capturePath = join(stateDir, `${schedule.date}.capture.json`);
|
||||||
|
const savedCapture = dryRun ? null : await readJson(capturePath);
|
||||||
|
if (!savedCapture && !immediate && Date.now() > +schedule.sunrise + THREADS.maxLatenessMs) {
|
||||||
|
console.log(`${schedule.date}: sunrise has passed; skipping late capture`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const captured = savedCapture ?? await captureSunrise(schedule, immediate);
|
||||||
|
const { images, capturedAt } = captured;
|
||||||
|
if (!Array.isArray(images) || images.length !== THREADS.angles.length) {
|
||||||
|
throw new Error('Saved capture is not a two-angle capture; archive the old capture record before running again');
|
||||||
|
}
|
||||||
|
const sunrise = new Date(captured.sunrise);
|
||||||
|
if (dryRun) { console.log('Dry run: no Threads API calls or publication records'); return; }
|
||||||
|
if (!savedCapture) await saveJson(capturePath, captured);
|
||||||
|
// Authentication/network outages must not lose the time-sensitive capture.
|
||||||
|
const token = await accessToken();
|
||||||
|
for (const { imageUrl, imagePath } of images) {
|
||||||
|
if (new URL(imageUrl).protocol !== 'https:') throw new Error('Public image URL must use HTTPS');
|
||||||
|
const check = await fetch(imageUrl, { signal: AbortSignal.timeout(THREADS.requestTimeoutMs) });
|
||||||
|
const remoteImage = Buffer.from(await check.arrayBuffer());
|
||||||
|
if (!check.ok || !remoteImage.equals(await readFile(imagePath))) throw new Error('Public image URL does not serve the captured image');
|
||||||
|
}
|
||||||
|
const text = `🌅 오늘의 관악산 일출\n${schedule.date} · ${kstClock(sunrise)} KST\n관악산 정상의 일출 시각을 담은 3D 시뮬레이션입니다.\n${siteUrl.origin}`;
|
||||||
|
const record = { date: schedule.date, sunrise: sunrise.toISOString(), capturedAt, images, status: 'publishing' };
|
||||||
|
const postId = await publishCarousel(token, images.map((image: { imageUrl: string }) => image.imageUrl), text, async containerId => {
|
||||||
|
// Atomic claim survives restarts and blocks concurrent/ambiguous publication retries.
|
||||||
|
await writeFile(recordPath, JSON.stringify({ ...record, containerId }, null, 2), { flag: 'wx', mode: 0o600 });
|
||||||
|
});
|
||||||
|
const pending = await readJson(recordPath);
|
||||||
|
await saveJson(recordPath, { ...pending, status: 'published', postId });
|
||||||
|
console.log(`Threads published: ${postId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function captureSunrise(schedule: ReturnType<typeof dailySchedule>, immediate: boolean) {
|
||||||
|
console.log(`Sunrise estimate: ${schedule.sunrise.toISOString()} (${kstClock(schedule.sunrise)} KST)`);
|
||||||
|
if (!immediate) await waitUntil(schedule.prepareAt);
|
||||||
|
await mkdir(imageDir, { recursive: true });
|
||||||
|
const browser = await chromium.launch({ headless: true, chromiumSandbox: true,
|
||||||
|
args: ['--use-angle=swiftshader', '--enable-unsafe-swiftshader'] });
|
||||||
|
const images: { name: string; imagePath: string; imageUrl: string }[] = [];
|
||||||
|
let sunrise: Date;
|
||||||
|
let capturedAt: string;
|
||||||
|
try {
|
||||||
|
const page = await browser.newPage({ viewport: THREADS.viewport, deviceScaleFactor: 1, timezoneId: 'Asia/Seoul' });
|
||||||
|
const url = new URL(siteUrl);
|
||||||
|
url.searchParams.set('mode', 'solo'); url.searchParams.set('site', 'gwanaksan');
|
||||||
|
url.searchParams.set('capture', 'threads');
|
||||||
|
await page.goto(url.href, { waitUntil: 'domcontentloaded', timeout: 120_000 });
|
||||||
|
await page.waitForFunction(() => Boolean(window.sunriseThreads), undefined, { timeout: 180_000 });
|
||||||
|
await page.waitForFunction(() => !document.getElementById('lobby') || document.getElementById('lobby')!.hidden);
|
||||||
|
sunrise = new Date(await page.evaluate(() => window.sunriseThreads!.sunrise()));
|
||||||
|
if (!Number.isFinite(+sunrise) || kstFields(sunrise).date !== schedule.date) throw new Error('Unexpected page sunrise date');
|
||||||
|
console.log(`Page sunrise: ${sunrise.toISOString()} (${kstClock(sunrise)} KST)`);
|
||||||
|
if (!immediate) {
|
||||||
|
if (Date.now() > +sunrise + THREADS.maxLatenessMs) throw new Error('Page loaded after capture deadline');
|
||||||
|
await waitUntil(sunrise);
|
||||||
|
}
|
||||||
|
const capture = await page.evaluate(() => window.sunriseThreads!.capture());
|
||||||
|
capturedAt = capture.capturedAt;
|
||||||
|
if (!immediate && Math.abs(Date.parse(capturedAt) - +sunrise) > THREADS.maxLatenessMs) throw new Error('Capture missed sunrise deadline');
|
||||||
|
if (capture.images?.length !== THREADS.angles.length) throw new Error('Deploy the two-angle capture frontend first');
|
||||||
|
for (const [index, shot] of capture.images.entries()) {
|
||||||
|
if (shot.name !== THREADS.angles[index].name) throw new Error('Unexpected capture angle order');
|
||||||
|
const filename = `${schedule.date}-${randomUUID()}-${shot.name}.jpg`;
|
||||||
|
const imagePath = join(imageDir, filename);
|
||||||
|
const imageUrl = new URL(filename, publicUrl.href.replace(/\/?$/, '/')).href;
|
||||||
|
const image = Buffer.from(shot.image.split(',')[1], 'base64');
|
||||||
|
if (image.length < 1000) throw new Error('Capture is empty');
|
||||||
|
await writeFile(imagePath, image);
|
||||||
|
images.push({ name: shot.name, imagePath, imageUrl });
|
||||||
|
console.log(`Saved ${shot.name} capture: ${imagePath}`);
|
||||||
|
}
|
||||||
|
} finally { await browser.close(); }
|
||||||
|
return { images, capturedAt, sunrise: sunrise.toISOString() };
|
||||||
|
}
|
||||||
|
|
||||||
|
void main().catch(error => {
|
||||||
|
// Avoid logging fetch error causes/URLs that may contain credentials.
|
||||||
|
console.error(error instanceof Error ? error.message : 'Threads job failed');
|
||||||
|
process.exitCode = 1;
|
||||||
|
});
|
||||||
@@ -25,3 +25,9 @@ export function calculateSun(date: Date, observer: ObserverConfig): SunState {
|
|||||||
standardSunrise: standard.sunrise, standardSunset: standard.sunset,
|
standardSunrise: standard.sunrise, standardSunset: standard.sunset,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function sunriseAzimuth(date: Date, observer: ObserverConfig): number {
|
||||||
|
const sunrise = calculateSun(date, observer).sunrise;
|
||||||
|
return sunrise && Number.isFinite(+sunrise)
|
||||||
|
? getPosition(sunrise, observer.latitude, observer.longitude).azimuth : 90;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
export const FLASHLIGHT = {
|
export const FLASHLIGHT = {
|
||||||
url: '/characters/flashlight.glb?v=1',
|
url: '/characters/flashlight.glb?v=1',
|
||||||
lumens: 600,
|
lumens: 600,
|
||||||
|
poseTransitionSeconds: 0.35,
|
||||||
bloomStrengthScale: 0.08,
|
bloomStrengthScale: 0.08,
|
||||||
bloomThreshold: 4,
|
bloomThreshold: 4,
|
||||||
bloomRadius: 0.03,
|
bloomRadius: 0.03,
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
export const RAIN_GEAR = {
|
export const RAIN_GEAR = {
|
||||||
umbrellaUrl: '/characters/umbrella.glb?v=1',
|
umbrellaUrl: '/characters/umbrella.glb?v=2',
|
||||||
|
umbrellaTransitionSeconds: 0.45,
|
||||||
|
umbrellaGripDirection: [.05, -.45, .7],
|
||||||
raincoatUrl: '/characters/raincoat.glb?v=5',
|
raincoatUrl: '/characters/raincoat.glb?v=5',
|
||||||
} as const;
|
} as const;
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export const SCREENSHOT = {
|
||||||
|
objectUrlLifetimeMs: 60000,
|
||||||
|
} as const;
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
export const THREADS = {
|
||||||
|
siteUrl: 'https://sunrise.horoli.kr',
|
||||||
|
apiUrl: 'https://graph.threads.net',
|
||||||
|
prepareMs: 5 * 60_000,
|
||||||
|
maxLatenessMs: 30_000,
|
||||||
|
requestTimeoutMs: 30_000,
|
||||||
|
mediaPollMs: 5_000,
|
||||||
|
mediaPollAttempts: 24,
|
||||||
|
tokenRefreshMs: 7 * 86_400_000,
|
||||||
|
viewport: { width: 1080, height: 1728 },
|
||||||
|
angles: [
|
||||||
|
{ name: 'sunrise', offset: 0, pitch: 0, alt: '일출을 바라보는 캐릭터의 뒷모습과 관악산 풍경' },
|
||||||
|
{ name: 'portrait', offset: 200, pitch: 20, alt: '일출 시각 관악산 정상의 캐릭터 정면과 하늘' },
|
||||||
|
],
|
||||||
|
} as const;
|
||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
export const DIALOG_TITLES = {
|
export const DIALOG_TITLES = {
|
||||||
menu: '메뉴', location: '장소', time: '날짜·시간', information: '관측 정보',
|
menu: '메뉴', location: '장소', time: '날짜·시간', information: '관측 정보',
|
||||||
view: '지도·시점', session: '닉네임·접속 상태',
|
view: '지도·시점', session: '닉네임·접속 상태',
|
||||||
settings: '설정·조작 방법', sources: '출처·오픈소스',
|
settings: '설정·조작 방법', sources: '출처·오픈소스', screenshot: '화면 촬영',
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export const EQUIPMENT_NOTICE = {
|
export const EQUIPMENT_NOTICE = {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ export type RainGearItem = 'umbrella' | 'raincoat' | 'hood';
|
|||||||
export function createRainGearState() {
|
export function createRainGearState() {
|
||||||
const worn = { umbrella: false, raincoat: false, hood: false };
|
const worn = { umbrella: false, raincoat: false, hood: false };
|
||||||
let available = false;
|
let available = false;
|
||||||
|
let awaitingArrivalWeather = true;
|
||||||
return {
|
return {
|
||||||
get available() { return available; },
|
get available() { return available; },
|
||||||
get umbrella() { return worn.umbrella; },
|
get umbrella() { return worn.umbrella; },
|
||||||
@@ -14,8 +15,12 @@ export function createRainGearState() {
|
|||||||
worn.raincoat = state.raincoat;
|
worn.raincoat = state.raincoat;
|
||||||
worn.hood = state.raincoat && state.hood;
|
worn.hood = state.raincoat && state.hood;
|
||||||
},
|
},
|
||||||
updateWeather(rain: number, snowfall: number) {
|
updateWeather(rain: number | null, snowfall: number | null) {
|
||||||
available = rain > 0 || snowfall > 0;
|
available = (rain ?? 0) > 0 || (snowfall ?? 0) > 0;
|
||||||
|
if (awaitingArrivalWeather && rain !== null) {
|
||||||
|
if (rain > 0) { worn.raincoat = true; worn.hood = true; }
|
||||||
|
awaitingArrivalWeather = false;
|
||||||
|
}
|
||||||
if (!available) { worn.umbrella = false; worn.raincoat = false; worn.hood = false; }
|
if (!available) { worn.umbrella = false; worn.raincoat = false; worn.hood = false; }
|
||||||
},
|
},
|
||||||
toggle(item: RainGearItem) {
|
toggle(item: RainGearItem) {
|
||||||
|
|||||||
+15
-3
@@ -1,7 +1,8 @@
|
|||||||
import './style.css';
|
import './style.css';
|
||||||
import { createDialogs } from './ui/dialogs.ts';
|
import { createDialogs } from './ui/dialogs.ts';
|
||||||
|
import { createScreenshot } from './ui/screenshot.ts';
|
||||||
import { loadSummitAssets } from './scene/summitAssets.ts';
|
import { loadSummitAssets } from './scene/summitAssets.ts';
|
||||||
import { calculateSun } from './astronomy/sunCalculator.ts';
|
import { calculateSun, sunriseAzimuth } from './astronomy/sunCalculator.ts';
|
||||||
import { createWorld } from './scene/world.ts';
|
import { createWorld } from './scene/world.ts';
|
||||||
import { createInformationPanel } from './ui/informationPanel.ts';
|
import { createInformationPanel } from './ui/informationPanel.ts';
|
||||||
import { TimeController } from './ui/timeController.ts';
|
import { TimeController } from './ui/timeController.ts';
|
||||||
@@ -104,6 +105,7 @@ export async function start(options: StartOptions = {}) {
|
|||||||
update();
|
update();
|
||||||
}, coast, structures, reconstructSummit, summitGeometry, sheep);
|
}, coast, structures, reconstructSummit, summitGeometry, sheep);
|
||||||
const viewEvents = new AbortController();
|
const viewEvents = new AbortController();
|
||||||
|
const screenshot = createScreenshot(() => world.captureFrame());
|
||||||
document.querySelectorAll<HTMLButtonElement>('[data-look]').forEach(button => {
|
document.querySelectorAll<HTMLButtonElement>('[data-look]').forEach(button => {
|
||||||
button.addEventListener('click', () => world.lookAtStructure(button.dataset.look!), { signal: viewEvents.signal });
|
button.addEventListener('click', () => world.lookAtStructure(button.dataset.look!), { signal: viewEvents.signal });
|
||||||
});
|
});
|
||||||
@@ -151,6 +153,9 @@ export async function start(options: StartOptions = {}) {
|
|||||||
update();
|
update();
|
||||||
});
|
});
|
||||||
world.resetPosition();
|
world.resetPosition();
|
||||||
|
const faceSunrise = () => world.lookAtSunrise(sunriseAzimuth(selectedDate, effectiveObserver));
|
||||||
|
faceSunrise();
|
||||||
|
let firstJoin = true;
|
||||||
const connectionStatus = document.getElementById('connection-status')!;
|
const connectionStatus = document.getElementById('connection-status')!;
|
||||||
const modeButton = document.getElementById('session-mode') as HTMLButtonElement;
|
const modeButton = document.getElementById('session-mode') as HTMLButtonElement;
|
||||||
const retryButton = document.getElementById('retry-connection') as HTMLButtonElement;
|
const retryButton = document.getElementById('retry-connection') as HTMLButtonElement;
|
||||||
@@ -166,7 +171,10 @@ export async function start(options: StartOptions = {}) {
|
|||||||
nickname.value = `방문자-${visitorId.slice(-4)}`;
|
nickname.value = `방문자-${visitorId.slice(-4)}`;
|
||||||
const multiplayer = createMultiplayerClient({
|
const multiplayer = createMultiplayerClient({
|
||||||
readPlayer: () => world.readPlayer(),
|
readPlayer: () => world.readPlayer(),
|
||||||
joined: player => world.applyPlayer(player),
|
joined: player => {
|
||||||
|
world.applyPlayer(player);
|
||||||
|
if (firstJoin) { faceSunrise(); firstJoin = false; }
|
||||||
|
},
|
||||||
correction: player => world.applyPlayer(player),
|
correction: player => world.applyPlayer(player),
|
||||||
snapshot: (state, selfId) => world.synchronizePlayers(state.players, selfId),
|
snapshot: (state, selfId) => world.synchronizePlayers(state.players, selfId),
|
||||||
weather: state => { if (sharedMode) applyWeather(state); },
|
weather: state => { if (sharedMode) applyWeather(state); },
|
||||||
@@ -230,9 +238,13 @@ export async function start(options: StartOptions = {}) {
|
|||||||
document.querySelector<HTMLFieldSetElement>('#time-controls')!.disabled = false;
|
document.querySelector<HTMLFieldSetElement>('#time-controls')!.disabled = false;
|
||||||
window.addEventListener('pagehide', (event) => {
|
window.addEventListener('pagehide', (event) => {
|
||||||
multiplayer.disconnect();
|
multiplayer.disconnect();
|
||||||
if (!event.persisted) { dialogs.dispose(); viewEvents.abort(); height.dispose(); time.dispose(); weather.dispose(); multiplayer.dispose(); world.dispose(); }
|
if (!event.persisted) { screenshot.dispose(); dialogs.dispose(); viewEvents.abort(); height.dispose(); time.dispose(); weather.dispose(); multiplayer.dispose(); world.dispose(); }
|
||||||
});
|
});
|
||||||
window.addEventListener('pageshow', event => { if (event.persisted && sharedMode) join(); });
|
window.addEventListener('pageshow', event => { if (event.persisted && sharedMode) join(); });
|
||||||
|
if (query.get('capture') === 'threads' && !sharedMode) {
|
||||||
|
const { installThreadsCapture } = await import('./ui/threadsCapture.ts');
|
||||||
|
installThreadsCapture(effectiveObserver, world);
|
||||||
|
}
|
||||||
options.ready?.();
|
options.ready?.();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
status.textContent = '지형을 불러오지 못했습니다';
|
status.textContent = '지형을 불러오지 못했습니다';
|
||||||
|
|||||||
+34
-7
@@ -44,6 +44,11 @@ export function createSheep(model: Group, gear?: { umbrella: Group; raincoat: Gr
|
|||||||
let foldedHood: Object3D | undefined;
|
let foldedHood: Object3D | undefined;
|
||||||
if (gear) {
|
if (gear) {
|
||||||
model.add(gear.umbrella, gear.raincoat);
|
model.add(gear.umbrella, gear.raincoat);
|
||||||
|
// Preserve the authored overhead position in the held pose, then let the hand carry it.
|
||||||
|
joints.ArmRight.quaternion.setFromUnitVectors(new Vector3(0, -1, 0),
|
||||||
|
new Vector3(...RAIN_GEAR.umbrellaGripDirection).normalize()).multiply(rest.ArmRight);
|
||||||
|
joints.ArmRight.attach(gear.umbrella);
|
||||||
|
joints.ArmRight.quaternion.copy(rest.ArmRight);
|
||||||
hood = gear.raincoat.getObjectByName('CoatHood');
|
hood = gear.raincoat.getObjectByName('CoatHood');
|
||||||
foldedHood = gear.raincoat.getObjectByName('CoatFoldedHood');
|
foldedHood = gear.raincoat.getObjectByName('CoatFoldedHood');
|
||||||
if (!hood || !foldedHood) throw new Error('우비 후드 모델 누락');
|
if (!hood || !foldedHood) throw new Error('우비 후드 모델 누락');
|
||||||
@@ -112,7 +117,10 @@ export function createSheep(model: Group, gear?: { umbrella: Group; raincoat: Gr
|
|||||||
const rigHood = copy(hood), rigFoldedHood = copy(foldedHood), rigUmbrella = copy(gear?.umbrella);
|
const rigHood = copy(hood), rigFoldedHood = copy(foldedHood), rigUmbrella = copy(gear?.umbrella);
|
||||||
const rigFlashlight = copy(gear?.flashlight);
|
const rigFlashlight = copy(gear?.flashlight);
|
||||||
let flashlightOn = false;
|
let flashlightOn = false;
|
||||||
|
let flashlightPose = 0;
|
||||||
|
let previousPoseTime: number | undefined;
|
||||||
let umbrellaOpen = false;
|
let umbrellaOpen = false;
|
||||||
|
let umbrellaPose = 0;
|
||||||
const rotation = new Quaternion();
|
const rotation = new Quaternion();
|
||||||
const down = new Vector3(0, -1, 0);
|
const down = new Vector3(0, -1, 0);
|
||||||
const aim = new Vector3();
|
const aim = new Vector3();
|
||||||
@@ -128,9 +136,9 @@ export function createSheep(model: Group, gear?: { umbrella: Group; raincoat: Gr
|
|||||||
clone() { return bindRig(group.clone(true), false); },
|
clone() { return bindRig(group.clone(true), false); },
|
||||||
setEquipment(umbrella: boolean, raincoat: boolean, hoodUp = false, flashlight = false) {
|
setEquipment(umbrella: boolean, raincoat: boolean, hoodUp = false, flashlight = false) {
|
||||||
flashlightOn = flashlight;
|
flashlightOn = flashlight;
|
||||||
if (rigFlashlight) rigFlashlight.visible = flashlight;
|
if (rigFlashlight) rigFlashlight.visible = flashlight || flashlightPose > 0;
|
||||||
umbrellaOpen = umbrella;
|
umbrellaOpen = umbrella;
|
||||||
if (rigUmbrella) rigUmbrella.visible = umbrella;
|
if (rigUmbrella) rigUmbrella.visible = umbrella || umbrellaPose > 0;
|
||||||
rigCoatParts.forEach(part => { part.visible = raincoat; });
|
rigCoatParts.forEach(part => { part.visible = raincoat; });
|
||||||
if (rigHood) rigHood.visible = raincoat && hoodUp;
|
if (rigHood) rigHood.visible = raincoat && hoodUp;
|
||||||
if (rigFoldedHood) rigFoldedHood.visible = raincoat && !hoodUp;
|
if (rigFoldedHood) rigFoldedHood.visible = raincoat && !hoodUp;
|
||||||
@@ -144,13 +152,24 @@ export function createSheep(model: Group, gear?: { umbrella: Group; raincoat: Gr
|
|||||||
for (const [material, color] of baseColors) material.color.copy(color).multiplyScalar(brightness);
|
for (const [material, color] of baseColors) material.color.copy(color).multiplyScalar(brightness);
|
||||||
},
|
},
|
||||||
pose(emote: Emote, elapsed: number, moving: boolean, time: number) {
|
pose(emote: Emote, elapsed: number, moving: boolean, time: number) {
|
||||||
const changed = moving || emote !== 'idle' || previousMoving !== moving || previousEmote !== emote;
|
const delta = Math.max(0, time - (previousPoseTime ?? time));
|
||||||
|
const step = delta / FLASHLIGHT.poseTransitionSeconds;
|
||||||
|
previousPoseTime = time;
|
||||||
|
const previousFlashlightPose = flashlightPose;
|
||||||
|
flashlightPose = flashlightOn ? Math.min(1, flashlightPose + step) : Math.max(0, flashlightPose - step);
|
||||||
|
const previousUmbrellaPose = umbrellaPose;
|
||||||
|
const umbrellaStep = delta / RAIN_GEAR.umbrellaTransitionSeconds;
|
||||||
|
umbrellaPose = umbrellaOpen ? Math.min(1, umbrellaPose + umbrellaStep) : Math.max(0, umbrellaPose - umbrellaStep);
|
||||||
|
const carryingUmbrella = umbrellaOpen || umbrellaPose > 0;
|
||||||
|
const changed = moving || emote !== 'idle' || previousMoving !== moving || previousEmote !== emote
|
||||||
|
|| previousFlashlightPose !== flashlightPose || flashlightPose !== Number(flashlightOn)
|
||||||
|
|| previousUmbrellaPose !== umbrellaPose || umbrellaPose !== Number(umbrellaOpen);
|
||||||
previousMoving = moving;
|
previousMoving = moving;
|
||||||
previousEmote = emote;
|
previousEmote = emote;
|
||||||
for (const [name, joint] of Object.entries(joints)) joint.quaternion.copy(rest[name]);
|
for (const [name, joint] of Object.entries(joints)) joint.quaternion.copy(rest[name]);
|
||||||
if (emote === 'wave') {
|
if (emote === 'wave') {
|
||||||
pointArm(umbrellaOpen ? 'ArmLeft' : 'ArmRight',
|
pointArm(carryingUmbrella ? 'ArmLeft' : 'ArmRight',
|
||||||
aim.set(umbrellaOpen ? -1 : 1, .35 + Math.sin(elapsed * 12) * .3, -.15));
|
aim.set(carryingUmbrella ? -1 : 1, .35 + Math.sin(elapsed * 12) * .3, -.15));
|
||||||
} else if (emote === 'pray') {
|
} else if (emote === 'pray') {
|
||||||
pointArm('ArmLeft', aim.set(.68, -.22, .64));
|
pointArm('ArmLeft', aim.set(.68, -.22, .64));
|
||||||
pointArm('ArmRight', aim.set(-.68, -.22, .64));
|
pointArm('ArmRight', aim.set(-.68, -.22, .64));
|
||||||
@@ -163,8 +182,16 @@ export function createSheep(model: Group, gear?: { umbrella: Group; raincoat: Gr
|
|||||||
joints.ArmRight.rotateX(stride * .6);
|
joints.ArmRight.rotateX(stride * .6);
|
||||||
}
|
}
|
||||||
// Keep the grip stable while the free arm and legs perform their own animations.
|
// Keep the grip stable while the free arm and legs perform their own animations.
|
||||||
if (umbrellaOpen) pointArm('ArmRight', aim.set(.05, -.45, .7));
|
if (umbrellaPose > 0) {
|
||||||
if (flashlightOn) pointArm('ArmLeft', aim.set(-.08, -.22, 1));
|
rotation.setFromUnitVectors(down, aim.set(...RAIN_GEAR.umbrellaGripDirection).normalize()).multiply(rest.ArmRight);
|
||||||
|
joints.ArmRight.quaternion.slerp(rotation, MathUtils.smoothstep(umbrellaPose, 0, 1));
|
||||||
|
}
|
||||||
|
if (rigUmbrella) rigUmbrella.visible = carryingUmbrella;
|
||||||
|
if (flashlightPose > 0) {
|
||||||
|
rotation.setFromUnitVectors(down, aim.set(-.08, -.22, 1).normalize()).multiply(rest.ArmLeft);
|
||||||
|
joints.ArmLeft.quaternion.slerp(rotation, MathUtils.smoothstep(flashlightPose, 0, 1));
|
||||||
|
}
|
||||||
|
if (rigFlashlight) rigFlashlight.visible = flashlightOn || flashlightPose > 0;
|
||||||
return changed;
|
return changed;
|
||||||
},
|
},
|
||||||
dispose() {
|
dispose() {
|
||||||
|
|||||||
+40
-1
@@ -414,6 +414,30 @@ export function createWorld(canvas: HTMLCanvasElement, terrainData: Awaited<Retu
|
|||||||
});
|
});
|
||||||
const world = {
|
const world = {
|
||||||
buildingCounts: buildings?.counts,
|
buildingCounts: buildings?.counts,
|
||||||
|
captureFrame(view?: PerspectiveCamera) {
|
||||||
|
const activeCamera = view ?? (overview ? overviewCamera : sheep ? followCamera : camera);
|
||||||
|
if (composer && !profile?.disablePost) {
|
||||||
|
renderPass.camera = activeCamera;
|
||||||
|
composer.render(0);
|
||||||
|
} else renderer.render(scene, activeCamera);
|
||||||
|
// Copy synchronously before WebGL clears the drawing buffer.
|
||||||
|
const frame = document.createElement('canvas');
|
||||||
|
frame.width = canvas.width;
|
||||||
|
frame.height = canvas.height;
|
||||||
|
frame.getContext('2d')!.drawImage(canvas, 0, 0);
|
||||||
|
return frame;
|
||||||
|
},
|
||||||
|
captureAngle(azimuth: number, pitch: number) {
|
||||||
|
// Orbit only the capture camera; keep the character facing the sunrise.
|
||||||
|
const view = camera.clone();
|
||||||
|
view.lookAt(view.position.clone().add(new Vector3(...directionFromAngles(azimuth, pitch))));
|
||||||
|
if (sheep) {
|
||||||
|
const orbit = view.clone();
|
||||||
|
updateThirdPersonCamera(orbit, view, (x, z) => harbour?.heightAt(x, z) ?? terrain.heightAt(x, z));
|
||||||
|
return world.captureFrame(orbit);
|
||||||
|
}
|
||||||
|
return world.captureFrame(view);
|
||||||
|
},
|
||||||
readPlayer(): PlayerUpdate {
|
readPlayer(): PlayerUpdate {
|
||||||
const yaw = sheep?.group.rotation.y ?? camera.rotation.y;
|
const yaw = sheep?.group.rotation.y ?? camera.rotation.y;
|
||||||
return { x: camera.position.x, z: camera.position.z, yaw: Math.atan2(Math.sin(yaw), Math.cos(yaw)),
|
return { x: camera.position.x, z: camera.position.z, yaw: Math.atan2(Math.sin(yaw), Math.cos(yaw)),
|
||||||
@@ -440,7 +464,7 @@ export function createWorld(canvas: HTMLCanvasElement, terrainData: Awaited<Retu
|
|||||||
clearPlayers() { remotePlayers?.clear(); rendering.invalidate(); },
|
clearPlayers() { remotePlayers?.clear(); rendering.invalidate(); },
|
||||||
setWeather(weather: WeatherSample | null) {
|
setWeather(weather: WeatherSample | null) {
|
||||||
weatherTarget = weatherAppearance(weather);
|
weatherTarget = weatherAppearance(weather);
|
||||||
rainGear.updateWeather(weatherTarget.rain, weatherTarget.snowfall);
|
rainGear.updateWeather(weather?.rain ?? null, weather?.snowfall ?? null);
|
||||||
sheep?.setEquipment(rainGear.umbrella, rainGear.raincoat, rainGear.hood, flashlightOn);
|
sheep?.setEquipment(rainGear.umbrella, rainGear.raincoat, rainGear.hood, flashlightOn);
|
||||||
refreshGear();
|
refreshGear();
|
||||||
// Do not blend an old precipitation type into the newly selected weather.
|
// Do not blend an old precipitation type into the newly selected weather.
|
||||||
@@ -475,6 +499,21 @@ export function createWorld(canvas: HTMLCanvasElement, terrainData: Awaited<Retu
|
|||||||
if (target) camera.lookAt(target.position.clone().add(new Vector3(0, id.includes('mast') ? 14 : id === 'weather-radar' ? 10 : 1, 0)));
|
if (target) camera.lookAt(target.position.clone().add(new Vector3(0, id.includes('mast') ? 14 : id === 'weather-radar' ? 10 : 1, 0)));
|
||||||
},
|
},
|
||||||
lookEast() { restoreView(); camera.lookAt(camera.position.clone().add(new Vector3(1, 0, 0))); },
|
lookEast() { restoreView(); camera.lookAt(camera.position.clone().add(new Vector3(1, 0, 0))); },
|
||||||
|
lookAtSunrise(azimuth: number) {
|
||||||
|
restoreView();
|
||||||
|
forward.set(...directionFromAngles(azimuth, 0));
|
||||||
|
camera.lookAt(camera.position.clone().add(forward));
|
||||||
|
if (sheep) {
|
||||||
|
sheep.group.rotation.y = Math.atan2(forward.x, forward.z);
|
||||||
|
updateThirdPersonCamera(followCamera, camera, (x, z) => harbour?.heightAt(x, z) ?? terrain.heightAt(x, z));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
lookAtSun() {
|
||||||
|
if (!solarState) return;
|
||||||
|
restoreView();
|
||||||
|
camera.lookAt(camera.position.clone().add(new Vector3(...directionFromAngles(solarState.azimuth, 0))));
|
||||||
|
if (sheep) updateThirdPersonCamera(followCamera, camera, (x, z) => harbour?.heightAt(x, z) ?? terrain.heightAt(x, z));
|
||||||
|
},
|
||||||
setEyeHeight(meters: number) {
|
setEyeHeight(meters: number) {
|
||||||
eyeHeight = meters;
|
eyeHeight = meters;
|
||||||
placeObserver();
|
placeObserver();
|
||||||
|
|||||||
+4
-2
@@ -29,6 +29,8 @@ button[aria-pressed=true]:hover, button.primary:hover { background: #f0cca0; }
|
|||||||
.scene-toolbar { position: absolute; top: max(18px, env(safe-area-inset-top)); left: max(18px, env(safe-area-inset-left)); right: max(18px, env(safe-area-inset-right)); display: flex; justify-content: space-between; gap: 12px; }
|
.scene-toolbar { position: absolute; top: max(18px, env(safe-area-inset-top)); left: max(18px, env(safe-area-inset-left)); right: max(18px, env(safe-area-inset-right)); display: flex; justify-content: space-between; gap: 12px; }
|
||||||
.scene-toolbar button, .emote-controls button, #minimap-overlay { pointer-events: auto; box-shadow: 0 3px 14px #0003; }
|
.scene-toolbar button, .emote-controls button, #minimap-overlay { pointer-events: auto; box-shadow: 0 3px 14px #0003; }
|
||||||
.toolbar-end, .button-row { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
.toolbar-end, .button-row { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
||||||
|
#screenshot-status { position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%); max-width: calc(100vw - 32px); margin: 0; padding: 12px 18px; border-radius: 14px; background: #18232ef2; color: #eef1f4; font-size: 13px; pointer-events: none; animation: screenshot-status 4s forwards; }
|
||||||
|
@keyframes screenshot-status { 0%, 80% { opacity: 1; } 100% { opacity: 0; } }
|
||||||
.emote-controls { position: absolute; right: max(18px, env(safe-area-inset-right)); bottom: max(24px, env(safe-area-inset-bottom)); display: flex; gap: 10px; }
|
.emote-controls { position: absolute; right: max(18px, env(safe-area-inset-right)); bottom: max(24px, env(safe-area-inset-bottom)); display: flex; gap: 10px; }
|
||||||
.emote-controls button { flex-direction: column; gap: 6px; min-width: 70px; padding: 12px 14px; background: #eee5d6; border-color: #fff3dd80; color: #3a382f; font-size: 12px; }
|
.emote-controls button { flex-direction: column; gap: 6px; min-width: 70px; padding: 12px 14px; background: #eee5d6; border-color: #fff3dd80; color: #3a382f; font-size: 12px; }
|
||||||
.emote-controls button:hover:not(:disabled) { background: #fff0d8; }
|
.emote-controls button:hover:not(:disabled) { background: #fff0d8; }
|
||||||
@@ -170,11 +172,11 @@ summary { padding: 12px 0; cursor: pointer; font-size: 14px; }
|
|||||||
.scene-toolbar { top: max(8px, env(safe-area-inset-top)); left: max(10px, env(safe-area-inset-left)); right: max(10px, env(safe-area-inset-right)); }
|
.scene-toolbar { top: max(8px, env(safe-area-inset-top)); left: max(10px, env(safe-area-inset-left)); right: max(10px, env(safe-area-inset-right)); }
|
||||||
.scene-toolbar button { width: 44px; height: 44px; min-height: 44px; padding: 10px; border-radius: 12px; }
|
.scene-toolbar button { width: 44px; height: 44px; min-height: 44px; padding: 10px; border-radius: 12px; }
|
||||||
#current-location { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); }
|
#current-location { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); }
|
||||||
#compass { top: calc(max(8px, env(safe-area-inset-top)) + 3px); width: calc(100vw - 140px); height: 52px; }
|
#compass { top: calc(max(8px, env(safe-area-inset-top)) + 3px); left: calc(50% - 26px); width: calc(100vw - 192px); height: 52px; }
|
||||||
.compass-scale { height: 34px; }
|
.compass-scale { height: 34px; }
|
||||||
.compass-tick { padding-top: 15px; font-size: 10px; }
|
.compass-tick { padding-top: 15px; font-size: 10px; }
|
||||||
.compass-tick.cardinal { font-size: 12px; }
|
.compass-tick.cardinal { font-size: 12px; }
|
||||||
.compass-tick:not(.cardinal) { font-size: 0; }
|
.compass-tick:not(.cardinal) { font-size: 0; color: transparent; text-shadow: none; }
|
||||||
.compass-bearing { font-size: 10px; }
|
.compass-bearing { font-size: 10px; }
|
||||||
.emote-controls { right: max(10px, env(safe-area-inset-right)); bottom: max(10px, env(safe-area-inset-bottom)); gap: 6px; }
|
.emote-controls { right: max(10px, env(safe-area-inset-right)); bottom: max(10px, env(safe-area-inset-bottom)); gap: 6px; }
|
||||||
.emote-controls button { min-width: 44px; min-height: 48px; padding: 5px 6px; gap: 3px; border-radius: 11px; font-size: 10px; }
|
.emote-controls button { min-width: 44px; min-height: 48px; padding: 5px 6px; gap: 3px; border-radius: 11px; font-size: 10px; }
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { SCREENSHOT } from '../constants/screenshot.ts';
|
||||||
|
|
||||||
|
export function createScreenshot(captureFrame: () => HTMLCanvasElement) {
|
||||||
|
const opener = document.querySelector<HTMLButtonElement>('#screenshot-open')!;
|
||||||
|
const dialog = document.querySelector<HTMLDialogElement>('#ui-dialog')!;
|
||||||
|
const status = document.getElementById('screenshot-status')!;
|
||||||
|
const events = new AbortController();
|
||||||
|
let busy = false;
|
||||||
|
opener.disabled = false;
|
||||||
|
opener.addEventListener('click', event => {
|
||||||
|
if (busy) event.stopPropagation();
|
||||||
|
}, { signal: events.signal });
|
||||||
|
|
||||||
|
for (const button of document.querySelectorAll<HTMLButtonElement>('[data-screenshot]')) {
|
||||||
|
button.addEventListener('click', async () => {
|
||||||
|
if (busy) return;
|
||||||
|
busy = true;
|
||||||
|
dialog.close();
|
||||||
|
status.hidden = true;
|
||||||
|
try {
|
||||||
|
const includeUi = button.dataset.screenshot === 'ui';
|
||||||
|
const toCanvas = includeUi ? (await import('html-to-image')).toCanvas : null;
|
||||||
|
if (toCanvas) await document.fonts.ready;
|
||||||
|
const frame = captureFrame();
|
||||||
|
if (toCanvas) {
|
||||||
|
const overlay = await toCanvas(document.body, {
|
||||||
|
width: innerWidth, height: innerHeight,
|
||||||
|
canvasWidth: frame.width, canvasHeight: frame.height, pixelRatio: 1,
|
||||||
|
filter: node => !node.matches?.('#world, #lobby, #ui-dialog, #screenshot-status, script'),
|
||||||
|
});
|
||||||
|
frame.getContext('2d')!.drawImage(overlay, 0, 0, frame.width, frame.height);
|
||||||
|
}
|
||||||
|
const blob = await new Promise<Blob>((resolve, reject) => {
|
||||||
|
frame.toBlob(value => value ? resolve(value) : reject(new Error('PNG encoding failed')), 'image/png');
|
||||||
|
});
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = url;
|
||||||
|
link.download = `sunrise-${new Date().toISOString().replace(/[:.]/g, '-')}-${includeUi ? 'ui' : 'scene'}.png`;
|
||||||
|
document.body.append(link);
|
||||||
|
link.click();
|
||||||
|
link.remove();
|
||||||
|
setTimeout(() => URL.revokeObjectURL(url), SCREENSHOT.objectUrlLifetimeMs);
|
||||||
|
status.textContent = 'PNG 이미지 다운로드를 시작했습니다.';
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Screenshot failed', error);
|
||||||
|
status.textContent = '사진을 저장하지 못했습니다. 다시 시도해 주세요.';
|
||||||
|
} finally {
|
||||||
|
busy = false;
|
||||||
|
status.hidden = false;
|
||||||
|
}
|
||||||
|
}, { signal: events.signal });
|
||||||
|
}
|
||||||
|
return { dispose() { events.abort(); opener.disabled = true; } };
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import type { ObserverConfig } from '../config/observer.ts';
|
||||||
|
import { calculateSun, sunriseAzimuth } from '../astronomy/sunCalculator.ts';
|
||||||
|
import { THREADS } from '../constants/threads.ts';
|
||||||
|
|
||||||
|
export interface ThreadsCapture {
|
||||||
|
sunrise(): string;
|
||||||
|
capture(): { images: { name: string; image: string }[]; capturedAt: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
declare global { interface Window { sunriseThreads?: ThreadsCapture } }
|
||||||
|
|
||||||
|
export function installThreadsCapture(observer: ObserverConfig,
|
||||||
|
world: { update: (state: ReturnType<typeof calculateSun>) => void; lookAtSunrise: (azimuth: number) => void;
|
||||||
|
captureAngle: (azimuth: number, pitch: number) => HTMLCanvasElement }) {
|
||||||
|
window.sunriseThreads = {
|
||||||
|
sunrise() {
|
||||||
|
const sunrise = calculateSun(new Date(), observer).sunrise;
|
||||||
|
if (!sunrise || !Number.isFinite(+sunrise)) throw new Error('No sunrise');
|
||||||
|
return sunrise.toISOString();
|
||||||
|
},
|
||||||
|
capture() {
|
||||||
|
const now = new Date();
|
||||||
|
world.update(calculateSun(now, observer));
|
||||||
|
const azimuth = sunriseAzimuth(now, observer);
|
||||||
|
world.lookAtSunrise(azimuth);
|
||||||
|
const images = THREADS.angles.map(angle => ({ name: angle.name,
|
||||||
|
image: world.captureAngle((azimuth + angle.offset) % 360, angle.pitch).toDataURL('image/jpeg', 0.95) }));
|
||||||
|
return { images, capturedAt: now.toISOString() };
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { calculateSun, sunriseAzimuth } from '../src/astronomy/sunCalculator.ts';
|
||||||
|
import { observer } from '../src/config/observer.ts';
|
||||||
|
|
||||||
|
test('arrival faces the selected day’s sunrise, even when entering in the afternoon', () => {
|
||||||
|
for (const month of ['01', '06', '11']) {
|
||||||
|
const date = new Date(`2026-${month}-02T15:00:00+09:00`);
|
||||||
|
const state = calculateSun(date, observer);
|
||||||
|
const heading = sunriseAzimuth(date, observer);
|
||||||
|
assert.equal(heading, calculateSun(state.sunrise!, observer).azimuth);
|
||||||
|
assert.ok(heading > 45 && heading < 135);
|
||||||
|
assert.ok(Math.abs(heading - state.azimuth) > 60);
|
||||||
|
}
|
||||||
|
});
|
||||||
+84
-2
@@ -19,6 +19,7 @@ test('authored flashlight stays in the left grip and uses lumen power only when
|
|||||||
assert.equal(beam.light.intensity, FLASHLIGHT.lumens / (2 * Math.PI * (1 - Math.cos(FLASHLIGHT.halfAngle))));
|
assert.equal(beam.light.intensity, FLASHLIGHT.lumens / (2 * Math.PI * (1 - Math.cos(FLASHLIGHT.halfAngle))));
|
||||||
sheep.setEquipment(true, false, false, true);
|
sheep.setEquipment(true, false, false, true);
|
||||||
sheep.pose('idle', 0, true, 1);
|
sheep.pose('idle', 0, true, 1);
|
||||||
|
sheep.pose('idle', 0, true, 2);
|
||||||
beam.update(true);
|
beam.update(true);
|
||||||
assert.equal(torch.visible, true);
|
assert.equal(torch.visible, true);
|
||||||
const direction = new Vector3().subVectors(beam.light.target.position, beam.light.position).normalize();
|
const direction = new Vector3().subVectors(beam.light.target.position, beam.light.position).normalize();
|
||||||
@@ -29,16 +30,47 @@ test('authored flashlight stays in the left grip and uses lumen power only when
|
|||||||
assert.ok(Math.abs(beam.light.position.x-before.x-3) < .001);
|
assert.ok(Math.abs(beam.light.position.x-before.x-3) < .001);
|
||||||
sheep.setEquipment(false, false, false, false);
|
sheep.setEquipment(false, false, false, false);
|
||||||
beam.update(false);
|
beam.update(false);
|
||||||
|
sheep.pose('idle', 0, false, 3);
|
||||||
assert.equal(torch.visible, false);
|
assert.equal(torch.visible, false);
|
||||||
assert.equal(beam.light.visible, false);
|
assert.equal(beam.light.visible, false);
|
||||||
beam.dispose(); sheep.dispose();
|
beam.dispose(); sheep.dispose();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('flashlight raises and lowers smoothly, retaining the prop until the arm is down', async () => {
|
||||||
|
const [model, umbrella, raincoat, torch] = await Promise.all(['red-sheep', 'umbrella', 'raincoat', 'flashlight'].map(asset));
|
||||||
|
const sheep = createSheep(model, { umbrella, raincoat, flashlight: torch });
|
||||||
|
const arm = model.getObjectByName('ArmLeft')!;
|
||||||
|
sheep.pose('idle', 0, false, 0);
|
||||||
|
const rest = arm.quaternion.clone();
|
||||||
|
sheep.setEquipment(false, false, false, true);
|
||||||
|
assert.equal(sheep.pose('idle', 0, false, .1), true, 'idle rendering continues during the transition');
|
||||||
|
const raising = arm.quaternion.clone();
|
||||||
|
sheep.pose('idle', 0, false, 1);
|
||||||
|
const held = arm.quaternion.clone();
|
||||||
|
assert.ok(raising.angleTo(rest) > .01 && raising.angleTo(held) > .01);
|
||||||
|
sheep.setEquipment(false, false, false, false);
|
||||||
|
assert.equal(torch.visible, true, 'the prop must not vanish before the lowering animation');
|
||||||
|
assert.equal(sheep.pose('idle', 0, false, 1.1), true);
|
||||||
|
const lowering = arm.quaternion.clone();
|
||||||
|
assert.ok(lowering.angleTo(held) > .01 && lowering.angleTo(rest) > .01);
|
||||||
|
sheep.setEquipment(false, false, false, true);
|
||||||
|
sheep.pose('idle', 0, false, 1.1);
|
||||||
|
assert.ok(arm.quaternion.angleTo(lowering) < 1e-6, 'reversing mid-animation does not snap');
|
||||||
|
sheep.setEquipment(false, false, false, false);
|
||||||
|
assert.equal(sheep.pose('idle', 0, false, 2), true, 'the final lowered frame is rendered');
|
||||||
|
assert.ok(arm.quaternion.angleTo(rest) < 1e-6);
|
||||||
|
assert.equal(torch.visible, false);
|
||||||
|
assert.equal(sheep.pose('idle', 0, false, 2.1), false, 'idle rendering can stop after the transition');
|
||||||
|
sheep.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
test('rain equipment is independent of emotes, remains in snow, and clears in dry weather', () => {
|
test('rain equipment is independent of emotes, remains in snow, and clears in dry weather', () => {
|
||||||
const gear = createRainGearState();
|
const gear = createRainGearState();
|
||||||
const emote = createEmoteState();
|
const emote = createEmoteState();
|
||||||
assert.equal(gear.toggle('umbrella'), false);
|
assert.equal(gear.toggle('umbrella'), false);
|
||||||
gear.updateWeather(1, 0);
|
gear.updateWeather(1, 0);
|
||||||
|
assert.ok(gear.raincoat && gear.hood, 'rainy arrival starts with coat and hood');
|
||||||
|
gear.toggle('raincoat');
|
||||||
assert.equal(gear.toggle('hood'), false, 'hood requires the raincoat');
|
assert.equal(gear.toggle('hood'), false, 'hood requires the raincoat');
|
||||||
assert.ok(gear.toggle('umbrella'));
|
assert.ok(gear.toggle('umbrella'));
|
||||||
assert.ok(gear.toggle('raincoat'));
|
assert.ok(gear.toggle('raincoat'));
|
||||||
@@ -58,6 +90,23 @@ test('rain equipment is independent of emotes, remains in snow, and clears in dr
|
|||||||
assert.ok(!gear.available && !gear.umbrella && !gear.raincoat);
|
assert.ok(!gear.available && !gear.umbrella && !gear.raincoat);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('arrival equipment waits for known weather and never overrides a later manual choice', () => {
|
||||||
|
const gear = createRainGearState();
|
||||||
|
gear.updateWeather(null, null);
|
||||||
|
gear.updateWeather(2, 0);
|
||||||
|
assert.ok(gear.raincoat && gear.hood && !gear.umbrella);
|
||||||
|
gear.toggle('hood');
|
||||||
|
gear.updateWeather(3, 0);
|
||||||
|
assert.ok(gear.raincoat && !gear.hood);
|
||||||
|
gear.toggle('raincoat');
|
||||||
|
gear.updateWeather(4, 0);
|
||||||
|
assert.ok(!gear.raincoat && !gear.hood);
|
||||||
|
const dryArrival = createRainGearState();
|
||||||
|
dryArrival.updateWeather(0, 0);
|
||||||
|
dryArrival.updateWeather(2, 0);
|
||||||
|
assert.ok(!dryArrival.raincoat && !dryArrival.hood, 'only arrival gets the default outfit');
|
||||||
|
});
|
||||||
|
|
||||||
async function asset(name: string) {
|
async function asset(name: string) {
|
||||||
const bytes = await readFile(new URL(`../public/characters/${name}.glb`, import.meta.url));
|
const bytes = await readFile(new URL(`../public/characters/${name}.glb`, import.meta.url));
|
||||||
return (await new GLTFLoader().parseAsync(bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength), '')).scene;
|
return (await new GLTFLoader().parseAsync(bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength), '')).scene;
|
||||||
@@ -79,16 +128,49 @@ test('Blender gear follows the sheep joints and keeps the umbrella grip through
|
|||||||
sheep.setEquipment(true, true, true);
|
sheep.setEquipment(true, true, true);
|
||||||
assert.ok(hood.visible && !folded.visible);
|
assert.ok(hood.visible && !folded.visible);
|
||||||
sheep.pose('idle', 0, true, 1);
|
sheep.pose('idle', 0, true, 1);
|
||||||
|
sheep.pose('idle', 0, true, 2);
|
||||||
const grip = right.quaternion.clone();
|
const grip = right.quaternion.clone();
|
||||||
const leftWalking = left.quaternion.clone();
|
const leftWalking = left.quaternion.clone();
|
||||||
sheep.pose('wave', .2, false, 1);
|
sheep.pose('wave', .2, false, 2);
|
||||||
assert.ok(right.quaternion.angleTo(grip) < 1e-6);
|
assert.ok(right.quaternion.angleTo(grip) < 1e-6);
|
||||||
assert.ok(left.quaternion.angleTo(leftWalking) > .5);
|
assert.ok(left.quaternion.angleTo(leftWalking) > .5);
|
||||||
sheep.pose('pray', .2, false, 1);
|
sheep.pose('pray', .2, false, 2);
|
||||||
assert.ok(right.quaternion.angleTo(grip) < 1e-6);
|
assert.ok(right.quaternion.angleTo(grip) < 1e-6);
|
||||||
assert.ok(umbrella.visible && raincoat.visible && sleeve.visible);
|
assert.ok(umbrella.visible && raincoat.visible && sleeve.visible);
|
||||||
sheep.setEquipment(false, false);
|
sheep.setEquipment(false, false);
|
||||||
|
sheep.pose('idle', 0, false, 3);
|
||||||
assert.ok(!umbrella.visible && !raincoat.visible && !sleeve.visible);
|
assert.ok(!umbrella.visible && !raincoat.visible && !sleeve.visible);
|
||||||
assert.equal(hood.visible, false);
|
assert.equal(hood.visible, false);
|
||||||
sheep.dispose();
|
sheep.dispose();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('umbrella follows the right hand through raising, reversal and lowering before hiding', async () => {
|
||||||
|
const [model, umbrella, raincoat] = await Promise.all(['red-sheep', 'umbrella', 'raincoat'].map(asset));
|
||||||
|
const sheep = createSheep(model, { umbrella, raincoat });
|
||||||
|
const arm = model.getObjectByName('ArmRight')!;
|
||||||
|
assert.ok(umbrella.parent === arm, 'umbrella is attached to the right hand');
|
||||||
|
sheep.pose('idle', 0, false, 0);
|
||||||
|
const rest = arm.quaternion.clone();
|
||||||
|
sheep.setEquipment(true, false);
|
||||||
|
assert.equal(sheep.pose('idle', 0, false, .225), true);
|
||||||
|
const halfway = arm.quaternion.clone();
|
||||||
|
const canopyHalfway = umbrella.getWorldPosition(new Vector3());
|
||||||
|
sheep.setEquipment(true, false); // Repeated network updates must not restart the transition.
|
||||||
|
sheep.pose('idle', 0, false, .45);
|
||||||
|
const held = arm.quaternion.clone();
|
||||||
|
assert.ok(halfway.angleTo(rest) > .01 && halfway.angleTo(held) > .01);
|
||||||
|
assert.ok(canopyHalfway.distanceTo(umbrella.getWorldPosition(new Vector3())) > .01);
|
||||||
|
sheep.setEquipment(false, false);
|
||||||
|
assert.equal(umbrella.visible, true);
|
||||||
|
assert.equal(sheep.pose('idle', 0, false, .675), true);
|
||||||
|
assert.ok(arm.quaternion.angleTo(halfway) < 1e-6);
|
||||||
|
sheep.setEquipment(true, false);
|
||||||
|
sheep.pose('idle', 0, false, .675);
|
||||||
|
assert.ok(arm.quaternion.angleTo(halfway) < 1e-6, 'reversal preserves the current pose');
|
||||||
|
sheep.setEquipment(false, false);
|
||||||
|
assert.equal(sheep.pose('idle', 0, false, 1.2), true);
|
||||||
|
assert.ok(arm.quaternion.angleTo(rest) < 1e-6);
|
||||||
|
assert.equal(umbrella.visible, false);
|
||||||
|
assert.equal(sheep.pose('idle', 0, false, 1.3), false);
|
||||||
|
sheep.dispose();
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { test } from 'node:test';
|
||||||
|
import { loadThreadsConfig } from '../server/threads/config.ts';
|
||||||
|
|
||||||
|
test('Threads JSON sample loads with relative paths and no embedded token', () => {
|
||||||
|
const config = loadThreadsConfig(new URL('../.env.threads.sample.json', import.meta.url));
|
||||||
|
assert.equal(config.accessToken, '');
|
||||||
|
assert.equal(config.siteUrl, 'https://sunrise.horoli.kr');
|
||||||
|
assert.equal(config.imageDir, 'dist/threads-captures');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('missing configuration is permitted only for capture preview', () => {
|
||||||
|
const missing = new URL('../.tmp/threads-config-does-not-exist.json', import.meta.url);
|
||||||
|
assert.throws(() => loadThreadsConfig(missing), /copy .env.threads.sample.json/);
|
||||||
|
assert.equal(loadThreadsConfig(missing, true).accessToken, '');
|
||||||
|
});
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { calculateSun } from '../src/astronomy/sunCalculator.ts';
|
||||||
|
import { observer } from '../src/config/observer.ts';
|
||||||
|
import { dailySchedule, publishImage, publishCarousel } from '../server/threads/publisher.ts';
|
||||||
|
|
||||||
|
test('schedule uses the Korean date and existing SunCalc sunrise', () => {
|
||||||
|
const now = new Date('2026-11-01T18:00:00Z');
|
||||||
|
const schedule = dailySchedule(now);
|
||||||
|
assert.equal(schedule.date, '2026-11-02');
|
||||||
|
assert.equal(schedule.sunrise.getTime(), calculateSun(now, observer).sunrise!.getTime());
|
||||||
|
assert.equal(+schedule.sunrise - +schedule.prepareAt, 300_000);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('publishing waits for ready media and persists intent before sending', async () => {
|
||||||
|
const events: string[] = [];
|
||||||
|
const replies = [{ id: 'container' }, { status: 'IN_PROGRESS' }, { status: 'FINISHED' }, { id: 'post' }];
|
||||||
|
const result = await publishImage('secret', 'https://example.com/image.jpg', 'caption', async () => { events.push('persist'); },
|
||||||
|
async (url, init) => {
|
||||||
|
events.push(String(url).includes('threads_publish') ? 'publish' : 'request');
|
||||||
|
assert.equal(new Headers(init?.headers).get('Authorization'), 'Bearer secret');
|
||||||
|
return new Response(JSON.stringify(replies.shift()));
|
||||||
|
}, async () => {});
|
||||||
|
assert.equal(result, 'post');
|
||||||
|
assert.deepEqual(events.slice(-2), ['persist', 'publish']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('failed media never reaches publish', async () => {
|
||||||
|
const replies = [{ id: 'container' }, { status: 'ERROR' }];
|
||||||
|
await assert.rejects(publishImage('secret', 'https://example.com/image.jpg', 'caption', async () => {
|
||||||
|
assert.fail('must not publish');
|
||||||
|
}, async () => new Response(JSON.stringify(replies.shift())), async () => {}), /ERROR/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('another publisher owning the daily record prevents the publish request', async () => {
|
||||||
|
let requests = 0;
|
||||||
|
const replies = [{ id: 'container' }, { status: 'FINISHED' }];
|
||||||
|
await assert.rejects(publishImage('secret', 'https://example.com/image.jpg', 'caption', async () => {
|
||||||
|
throw new Error('EEXIST');
|
||||||
|
}, async () => { requests++; return new Response(JSON.stringify(replies.shift())); }, async () => {}), /EEXIST/);
|
||||||
|
assert.equal(requests, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ambiguous publish response is never automatically retried', async () => {
|
||||||
|
let requests = 0;
|
||||||
|
let claimed = false;
|
||||||
|
const replies = [{ id: 'container' }, { status: 'FINISHED' }];
|
||||||
|
await assert.rejects(publishImage('secret', 'https://example.com/image.jpg', 'caption', async () => {
|
||||||
|
claimed = true;
|
||||||
|
}, async () => {
|
||||||
|
requests++;
|
||||||
|
if (requests === 3) throw new Error('Connection lost');
|
||||||
|
return new Response(JSON.stringify(replies.shift()));
|
||||||
|
}, async () => {}), /Connection lost/);
|
||||||
|
assert.equal(claimed, true);
|
||||||
|
assert.equal(requests, 3);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('carousel preserves photo order, waits for children and publishes only the parent', async () => {
|
||||||
|
const posts: URLSearchParams[] = [];
|
||||||
|
const replies = [{ id: 'back' }, { status: 'FINISHED' }, { id: 'front' },
|
||||||
|
{ status: 'IN_PROGRESS' }, { status: 'FINISHED' }, { id: 'album' }, { status: 'FINISHED' }, { id: 'post' }];
|
||||||
|
let claimed = '';
|
||||||
|
const result = await publishCarousel('secret', ['https://example.com/back.jpg', 'https://example.com/front.jpg'], 'caption',
|
||||||
|
async id => { claimed = id; }, async (_url, init) => {
|
||||||
|
if (init?.method === 'POST') posts.push(init.body as URLSearchParams);
|
||||||
|
return new Response(JSON.stringify(replies.shift()));
|
||||||
|
}, async () => {});
|
||||||
|
assert.equal(result, 'post');
|
||||||
|
assert.equal(posts[0].get('image_url'), 'https://example.com/back.jpg');
|
||||||
|
assert.equal(posts[1].get('image_url'), 'https://example.com/front.jpg');
|
||||||
|
assert.equal(posts[0].get('is_carousel_item'), 'true');
|
||||||
|
assert.equal(posts[1].get('is_carousel_item'), 'true');
|
||||||
|
assert.equal(posts[2].get('media_type'), 'CAROUSEL');
|
||||||
|
assert.equal(posts[2].get('children'), 'back,front');
|
||||||
|
assert.equal(posts[2].get('text'), 'caption');
|
||||||
|
assert.equal(claimed, 'album');
|
||||||
|
assert.equal(posts[3].get('creation_id'), 'album');
|
||||||
|
assert.equal(posts.length, 4);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a failed second image prevents a partial carousel publication', async () => {
|
||||||
|
const replies = [{ id: 'back' }, { status: 'FINISHED' }, { id: 'front' }, { status: 'ERROR' }];
|
||||||
|
await assert.rejects(publishCarousel('secret', ['https://example.com/back.jpg', 'https://example.com/front.jpg'], 'caption',
|
||||||
|
async () => assert.fail('must not publish'), async () => new Response(JSON.stringify(replies.shift())), async () => {}), /ERROR/);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user