diff --git a/.env.threads.sample.json b/.env.threads.sample.json new file mode 100644 index 0000000..9394afd --- /dev/null +++ b/.env.threads.sample.json @@ -0,0 +1,7 @@ +{ + "accessToken": "", + "siteUrl": "https://sunrise.horoli.kr", + "stateDir": "data/threads", + "imageDir": "dist/threads-captures", + "imageBaseUrl": "https://sunrise.horoli.kr/threads-captures/" +} diff --git a/.gitignore b/.gitignore index d5c33ae..b3b85e9 100644 --- a/.gitignore +++ b/.gitignore @@ -18,4 +18,6 @@ __pycache__/ # Environment secrets (keep the documented template) .env.* +!/.env.threads.sample.json /.config.json +/data/threads/ diff --git a/README.md b/README.md index bc59db4..6a722e4 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,8 @@ # 해보러가기 +매일 일출 시각에 화면을 촬영하고 Meta Threads에 게시하는 Linux 작업은 [자동 게시 설정 가이드](docs/threads-automation.md)를 참고하세요. + ## 현재 과천 건물: VWorld 단독 (2026-10-30) OSM 건물의 외곽선·높이·층수와 OSM 대조 기반 선별을 제거했다. VWorld 원본에서 독립적으로 재생성한 **6,743개**를 표시한다(높이 자료 1,756개 / 층수 환산 953개 / 임시 높이 4,034개). VWorld 내부 겹침 183개를 제외했다. 아래 OSM·VWorld 통합 설명은 이전 작업 기록이며 현재 구성에 적용되지 않는다. 해안선·정상 구조물 위치의 OSM 사용은 유지한다. diff --git a/deploy/sunrise-threads.service b/deploy/sunrise-threads.service new file mode 100644 index 0000000..ae2381b --- /dev/null +++ b/deploy/sunrise-threads.service @@ -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 diff --git a/deploy/sunrise-threads.timer b/deploy/sunrise-threads.timer new file mode 100644 index 0000000..0cf2d49 --- /dev/null +++ b/deploy/sunrise-threads.timer @@ -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 diff --git a/docs/threads-automation.md b/docs/threads-automation.md new file mode 100644 index 0000000..396ac97 --- /dev/null +++ b/docs/threads-automation.md @@ -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`). diff --git a/index.html b/index.html index 032fa97..0edec66 100644 --- a/index.html +++ b/index.html @@ -51,6 +51,7 @@
+
@@ -101,6 +102,13 @@

메뉴

+
+ diff --git a/package-lock.json b/package-lock.json index a737e34..41db83f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,7 +8,9 @@ "name": "gwanaksan-sunrise", "version": "0.1.0", "dependencies": { + "html-to-image": "^1.11.13", "mongodb": "^7.7.0", + "playwright": "^1.64.1", "socket.io": "^4.8.4", "socket.io-client": "^4.8.4", "suncalc": "2.0.1", @@ -1179,6 +1181,12 @@ "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": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/memory-pager/-/memory-pager-1.5.0.tgz", @@ -1335,6 +1343,33 @@ "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": { "version": "8.5.32", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.32.tgz", diff --git a/package.json b/package.json index 49a93ff..3146d37 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,8 @@ "node": ">=22.18.0" }, "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", "prepare:buildings": "node scripts/prepare-building-assets.ts", "check:buildings": "node scripts/prepare-building-assets.ts --check", @@ -25,7 +27,9 @@ "verify:solar": "node tests/solar-report.ts" }, "dependencies": { + "html-to-image": "^1.11.13", "mongodb": "^7.7.0", + "playwright": "^1.64.1", "socket.io": "^4.8.4", "socket.io-client": "^4.8.4", "suncalc": "2.0.1", diff --git a/public/buildings/gwacheon.baked.json b/public/buildings/gwacheon.baked.json index 82d448c..19c807d 100644 --- a/public/buildings/gwacheon.baked.json +++ b/public/buildings/gwacheon.baked.json @@ -1,6 +1,6 @@ { "version": 1, - "sourceHash": "ec19bec7b7dded0b4ed8e0733dea4ff2a87976869461cde9a99f6608c6b7836b", + "sourceHash": "b1af9482e14c57eb27998ef06b39c129d02770cf3eba5037b9983147c64f7dba", "bufferHash": "d8cf0a36a46572060189e2ca9e9a61e448fb3d0b441abc346b56b7c4603e0d65", "byteLength": 11036560, "terrain": { diff --git a/public/characters/umbrella.glb b/public/characters/umbrella.glb index 0d6d6e6..8040b20 100644 Binary files a/public/characters/umbrella.glb and b/public/characters/umbrella.glb differ diff --git a/public/licenses.txt b/public/licenses.txt index df50d8b..1c65f92 100644 --- a/public/licenses.txt +++ b/public/licenses.txt @@ -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, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 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. diff --git a/public/terrain/summit-mesh.json b/public/terrain/summit-mesh.json index c553dc8..74f9c41 100644 --- a/public/terrain/summit-mesh.json +++ b/public/terrain/summit-mesh.json @@ -1,6 +1,6 @@ { "version": 1, - "sourceHash": "6f03c38b2007114b94747b0abbcc26c56b408b0353fc3068b19cc17518542ecc", + "sourceHash": "407cca469fa95b26fe4f8da3cbc515620903ebb723c1aea08e59c4e2ca591acc", "bufferHash": "8fa1ab6646cacc15fc1e84ace034a71826a8dc50f9d5dc2c15aab39e290166d6", "byteLength": 16099324, "gzipBytes": 6369382, @@ -8,6 +8,6 @@ "vertices": 215769, "triangles": 406610, "originalAttributeBytes": 63431160, - "generateMs": 4164.3897, - "decodeMs": 16.178200000000288 + "generateMs": 4067.9287999999997, + "decodeMs": 17.9390999999996 } diff --git a/scripts/create-rain-gear.py b/scripts/create-rain-gear.py index af0e915..0770dac 100644 --- a/scripts/create-rain-gear.py +++ b/scripts/create-rain-gear.py @@ -1,5 +1,5 @@ """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 ROOT='D:/project/sunrise' 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') # 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) +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): vertices=[];faces=[] for row in range(9): - theta=(math.pi/2)*row/8 for col in range(5): - a=(sector+col/4)*math.pi/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))) + vertices.append(canopy_point(row/8,sector,col/4)) for row in range(8): for col in range(4): 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 solid=panel.modifiers.new('Canopy thickness','SOLIDIFY');solid.thickness=.018 bpy.ops.object.modifier_apply(modifier=solid.name) - a=sector*math.pi/4 - 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',[(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('Umbrella tip',(0,-.05,3.055),(.04,.04,.07),ink,umbrella) + stroke('Canopy rib',[canopy_point(i/16,sector,0) 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) + sphere('Rib tip',canopy_point(1,sector,0),(.025,.025,.025),ink,umbrella) +stroke('Umbrella tip',[(0,-.05,3.17),(0,-.05,3.33)],.022,ink,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') for joint_name in ('Body','ArmLeft','ArmRight'): @@ -174,6 +181,6 @@ def export(root,path): f.write(struct.pack(', 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, request: typeof fetch = fetch, + wait: (ms: number) => Promise = 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, request: typeof fetch = fetch, + wait: (ms: number) => Promise = 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, + request: typeof fetch, wait: (ms: number) => Promise) { + 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) { + 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'); +} diff --git a/server/threads/run.ts b/server/threads/run.ts new file mode 100644 index 0000000..d65867b --- /dev/null +++ b/server/threads/run.ts @@ -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, 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; +}); diff --git a/src/astronomy/sunCalculator.ts b/src/astronomy/sunCalculator.ts index 0c66e0c..ce8f313 100644 --- a/src/astronomy/sunCalculator.ts +++ b/src/astronomy/sunCalculator.ts @@ -25,3 +25,9 @@ export function calculateSun(date: Date, observer: ObserverConfig): SunState { 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; +} diff --git a/src/constants/flashlight.ts b/src/constants/flashlight.ts index ed8a77d..3da9e51 100644 --- a/src/constants/flashlight.ts +++ b/src/constants/flashlight.ts @@ -1,6 +1,7 @@ export const FLASHLIGHT = { url: '/characters/flashlight.glb?v=1', lumens: 600, + poseTransitionSeconds: 0.35, bloomStrengthScale: 0.08, bloomThreshold: 4, bloomRadius: 0.03, diff --git a/src/constants/rainGear.ts b/src/constants/rainGear.ts index d358434..eb94242 100644 --- a/src/constants/rainGear.ts +++ b/src/constants/rainGear.ts @@ -1,4 +1,6 @@ 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', } as const; diff --git a/src/constants/screenshot.ts b/src/constants/screenshot.ts new file mode 100644 index 0000000..a8cd447 --- /dev/null +++ b/src/constants/screenshot.ts @@ -0,0 +1,3 @@ +export const SCREENSHOT = { + objectUrlLifetimeMs: 60000, +} as const; diff --git a/src/constants/threads.ts b/src/constants/threads.ts new file mode 100644 index 0000000..8e4644c --- /dev/null +++ b/src/constants/threads.ts @@ -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; diff --git a/src/constants/ui.ts b/src/constants/ui.ts index 11e9c40..2b3c583 100644 --- a/src/constants/ui.ts +++ b/src/constants/ui.ts @@ -1,7 +1,7 @@ export const DIALOG_TITLES = { menu: '메뉴', location: '장소', time: '날짜·시간', information: '관측 정보', view: '지도·시점', session: '닉네임·접속 상태', - settings: '설정·조작 방법', sources: '출처·오픈소스', + settings: '설정·조작 방법', sources: '출처·오픈소스', screenshot: '화면 촬영', } as const; export const EQUIPMENT_NOTICE = { diff --git a/src/controls/rainGear.ts b/src/controls/rainGear.ts index 644449e..71204dd 100644 --- a/src/controls/rainGear.ts +++ b/src/controls/rainGear.ts @@ -4,6 +4,7 @@ export type RainGearItem = 'umbrella' | 'raincoat' | 'hood'; export function createRainGearState() { const worn = { umbrella: false, raincoat: false, hood: false }; let available = false; + let awaitingArrivalWeather = true; return { get available() { return available; }, get umbrella() { return worn.umbrella; }, @@ -14,8 +15,12 @@ export function createRainGearState() { worn.raincoat = state.raincoat; worn.hood = state.raincoat && state.hood; }, - updateWeather(rain: number, snowfall: number) { - available = rain > 0 || snowfall > 0; + updateWeather(rain: number | null, snowfall: number | null) { + 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; } }, toggle(item: RainGearItem) { diff --git a/src/main.ts b/src/main.ts index 0cd8bbf..a8f93fd 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,7 +1,8 @@ import './style.css'; import { createDialogs } from './ui/dialogs.ts'; +import { createScreenshot } from './ui/screenshot.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 { createInformationPanel } from './ui/informationPanel.ts'; import { TimeController } from './ui/timeController.ts'; @@ -104,6 +105,7 @@ export async function start(options: StartOptions = {}) { update(); }, coast, structures, reconstructSummit, summitGeometry, sheep); const viewEvents = new AbortController(); + const screenshot = createScreenshot(() => world.captureFrame()); document.querySelectorAll('[data-look]').forEach(button => { button.addEventListener('click', () => world.lookAtStructure(button.dataset.look!), { signal: viewEvents.signal }); }); @@ -151,6 +153,9 @@ export async function start(options: StartOptions = {}) { update(); }); world.resetPosition(); + const faceSunrise = () => world.lookAtSunrise(sunriseAzimuth(selectedDate, effectiveObserver)); + faceSunrise(); + let firstJoin = true; const connectionStatus = document.getElementById('connection-status')!; const modeButton = document.getElementById('session-mode') 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)}`; const multiplayer = createMultiplayerClient({ readPlayer: () => world.readPlayer(), - joined: player => world.applyPlayer(player), + joined: player => { + world.applyPlayer(player); + if (firstJoin) { faceSunrise(); firstJoin = false; } + }, correction: player => world.applyPlayer(player), snapshot: (state, selfId) => world.synchronizePlayers(state.players, selfId), weather: state => { if (sharedMode) applyWeather(state); }, @@ -230,9 +238,13 @@ export async function start(options: StartOptions = {}) { document.querySelector('#time-controls')!.disabled = false; window.addEventListener('pagehide', (event) => { 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(); }); + if (query.get('capture') === 'threads' && !sharedMode) { + const { installThreadsCapture } = await import('./ui/threadsCapture.ts'); + installThreadsCapture(effectiveObserver, world); + } options.ready?.(); } catch (error) { status.textContent = '지형을 불러오지 못했습니다'; diff --git a/src/scene/sheep.ts b/src/scene/sheep.ts index 83166f5..b27d0dd 100644 --- a/src/scene/sheep.ts +++ b/src/scene/sheep.ts @@ -44,6 +44,11 @@ export function createSheep(model: Group, gear?: { umbrella: Group; raincoat: Gr let foldedHood: Object3D | undefined; if (gear) { 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'); foldedHood = gear.raincoat.getObjectByName('CoatFoldedHood'); 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 rigFlashlight = copy(gear?.flashlight); let flashlightOn = false; + let flashlightPose = 0; + let previousPoseTime: number | undefined; let umbrellaOpen = false; + let umbrellaPose = 0; const rotation = new Quaternion(); const down = new Vector3(0, -1, 0); 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); }, setEquipment(umbrella: boolean, raincoat: boolean, hoodUp = false, flashlight = false) { flashlightOn = flashlight; - if (rigFlashlight) rigFlashlight.visible = flashlight; + if (rigFlashlight) rigFlashlight.visible = flashlight || flashlightPose > 0; umbrellaOpen = umbrella; - if (rigUmbrella) rigUmbrella.visible = umbrella; + if (rigUmbrella) rigUmbrella.visible = umbrella || umbrellaPose > 0; rigCoatParts.forEach(part => { part.visible = raincoat; }); if (rigHood) rigHood.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); }, 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; previousEmote = emote; for (const [name, joint] of Object.entries(joints)) joint.quaternion.copy(rest[name]); if (emote === 'wave') { - pointArm(umbrellaOpen ? 'ArmLeft' : 'ArmRight', - aim.set(umbrellaOpen ? -1 : 1, .35 + Math.sin(elapsed * 12) * .3, -.15)); + pointArm(carryingUmbrella ? 'ArmLeft' : 'ArmRight', + aim.set(carryingUmbrella ? -1 : 1, .35 + Math.sin(elapsed * 12) * .3, -.15)); } else if (emote === 'pray') { pointArm('ArmLeft', 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); } // Keep the grip stable while the free arm and legs perform their own animations. - if (umbrellaOpen) pointArm('ArmRight', aim.set(.05, -.45, .7)); - if (flashlightOn) pointArm('ArmLeft', aim.set(-.08, -.22, 1)); + if (umbrellaPose > 0) { + 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; }, dispose() { diff --git a/src/scene/world.ts b/src/scene/world.ts index da92453..b96a71c 100644 --- a/src/scene/world.ts +++ b/src/scene/world.ts @@ -414,6 +414,30 @@ export function createWorld(canvas: HTMLCanvasElement, terrainData: Awaited harbour?.heightAt(x, z) ?? terrain.heightAt(x, z)); + return world.captureFrame(orbit); + } + return world.captureFrame(view); + }, readPlayer(): PlayerUpdate { 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)), @@ -440,7 +464,7 @@ export function createWorld(canvas: HTMLCanvasElement, terrainData: Awaited 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) { eyeHeight = meters; placeObserver(); diff --git a/src/style.css b/src/style.css index 795bfa1..4a86b01 100644 --- a/src/style.css +++ b/src/style.css @@ -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 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; } +#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 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; } @@ -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 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%); } - #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-tick { padding-top: 15px; font-size: 10px; } .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; } .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; } diff --git a/src/ui/screenshot.ts b/src/ui/screenshot.ts new file mode 100644 index 0000000..65f3da6 --- /dev/null +++ b/src/ui/screenshot.ts @@ -0,0 +1,55 @@ +import { SCREENSHOT } from '../constants/screenshot.ts'; + +export function createScreenshot(captureFrame: () => HTMLCanvasElement) { + const opener = document.querySelector('#screenshot-open')!; + const dialog = document.querySelector('#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('[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((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; } }; +} diff --git a/src/ui/threadsCapture.ts b/src/ui/threadsCapture.ts new file mode 100644 index 0000000..07b4a9a --- /dev/null +++ b/src/ui/threadsCapture.ts @@ -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) => 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() }; + }, + }; +} diff --git a/tests/arrival.test.ts b/tests/arrival.test.ts new file mode 100644 index 0000000..40f6425 --- /dev/null +++ b/tests/arrival.test.ts @@ -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); + } +}); diff --git a/tests/rain-gear.test.ts b/tests/rain-gear.test.ts index eef5666..de6cd7b 100644 --- a/tests/rain-gear.test.ts +++ b/tests/rain-gear.test.ts @@ -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)))); sheep.setEquipment(true, false, false, true); sheep.pose('idle', 0, true, 1); + sheep.pose('idle', 0, true, 2); beam.update(true); assert.equal(torch.visible, true); 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); sheep.setEquipment(false, false, false, false); beam.update(false); + sheep.pose('idle', 0, false, 3); assert.equal(torch.visible, false); assert.equal(beam.light.visible, false); 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', () => { const gear = createRainGearState(); const emote = createEmoteState(); assert.equal(gear.toggle('umbrella'), false); 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.ok(gear.toggle('umbrella')); 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); }); +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) { 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; @@ -79,16 +128,49 @@ test('Blender gear follows the sheep joints and keeps the umbrella grip through sheep.setEquipment(true, true, true); assert.ok(hood.visible && !folded.visible); sheep.pose('idle', 0, true, 1); + sheep.pose('idle', 0, true, 2); const grip = right.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(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(umbrella.visible && raincoat.visible && sleeve.visible); sheep.setEquipment(false, false); + sheep.pose('idle', 0, false, 3); assert.ok(!umbrella.visible && !raincoat.visible && !sleeve.visible); assert.equal(hood.visible, false); 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(); +}); diff --git a/tests/threads-config.test.ts b/tests/threads-config.test.ts new file mode 100644 index 0000000..67524d5 --- /dev/null +++ b/tests/threads-config.test.ts @@ -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, ''); +}); diff --git a/tests/threads.test.ts b/tests/threads.test.ts new file mode 100644 index 0000000..2b7b152 --- /dev/null +++ b/tests/threads.test.ts @@ -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/); +});