test: verify bundled decoders and artifact budgets (#7)
This commit is contained in:
@@ -392,9 +392,12 @@ path와 host의 `node_modules`를 표준 build 입력으로 사용하지 않습
|
||||
문법과 repository-relative path로 작성합니다.
|
||||
- development, production build, E2E, optional asset processing은 서로 다른
|
||||
container target 또는 profile로 분리합니다. production artifact에 compiler,
|
||||
browser, fixture와 asset tool을 포함하지 않습니다.
|
||||
browser, test 전용 fixture와 asset tool을 포함하지 않습니다. `public/samples`의
|
||||
demo asset은 의도된 runtime content이므로 provenance·license gate를 별도로 적용합니다.
|
||||
- container process는 non-root user로 실행하고 source는 read-only mount를
|
||||
우선합니다. dependency cache와 build output만 별도 writable volume을 사용합니다.
|
||||
우선합니다. `npm ci`의 postinstall decoder staging은 writable image build layer에서
|
||||
끝내고, runtime container에서는 완성된 artifact만 read-only로 제공합니다.
|
||||
dependency cache와 build output만 별도 writable volume을 사용합니다.
|
||||
- host에서 직접 실행할 때 Vite는 loopback에만 bind합니다. container에서 port를
|
||||
publish할 때만 container 전용 설정으로 `0.0.0.0`에 bind하고 공개 network에
|
||||
노출하지 않습니다.
|
||||
@@ -407,13 +410,12 @@ path와 host의 `node_modules`를 표준 build 입력으로 사용하지 않습
|
||||
|
||||
## Linux 컨테이너 전환 검증
|
||||
|
||||
2026-07-29 기준 installed dependency 상태에서 typecheck, unit test 8개와 production
|
||||
build는 Linux/WSL2 x86_64에서 다시 통과했습니다. 기존 검증 기록의 Playwright E2E
|
||||
16개 통과 결과는 있으나 이번 문서 변경에 대한 E2E 재실행은 요청에 따라 뒤로
|
||||
미뤘습니다. tracked text file은 LF이며 lockfile에는 Linux glibc와 musl용 native
|
||||
optional package가 포함되어 있습니다. 따라서 application code를 Windows 전용
|
||||
구현에서 이식하는 작업은 거의 끝났지만, clean `npm ci`부터 시작하는 재현 가능한
|
||||
container 개발 환경의 정의와 현재 E2E 재검증은 아직 완료되지 않았습니다.
|
||||
2026-07-29 기준 installed dependency 상태에서 typecheck, unit test 8개, production
|
||||
build와 Playwright E2E 17개가 Linux/WSL2 x86_64에서 다시 통과했습니다. tracked
|
||||
text file은 LF이며 lockfile에는 Linux glibc와 musl용 native optional package가
|
||||
포함되어 있습니다. 따라서 application code의 Windows 종속성은 현재 검증 범위에서
|
||||
발견되지 않았지만, clean `npm ci`부터 시작하는 재현 가능한 container 개발 환경의
|
||||
정의와 검증은 아직 완료되지 않았습니다.
|
||||
|
||||
| 항목 | 현재 상태 | container 전환 판단 |
|
||||
|---|---|---|
|
||||
@@ -497,9 +499,11 @@ npm run verify
|
||||
- workspace typecheck 통과
|
||||
- Vitest 5개 file, 8개 test 통과
|
||||
- 2D·3D production build 통과
|
||||
- production artifact gate 통과: 27개 file, 8,149,402 bytes, 50 kB 이상 중복 0개
|
||||
- production artifact gate 통과:
|
||||
- 2D: 8개 file, 4,302,677 bytes, 50 kB 이상 중복 0개
|
||||
- 3D: 27개 file, 8,149,402 bytes, 50 kB 이상 중복 0개
|
||||
- 3D `dist`는 경량화 전 24,945,531 bytes에서 16,796,129 bytes(67.3%) 감소
|
||||
- Playwright 17개 E2E 통과, 21.9초
|
||||
- Playwright 17개 E2E 통과, 24.5초
|
||||
- build 실패는 없지만 2D main bundle, Three.js, web-ifc와 decoder JS에
|
||||
500 kB 초과 chunk 경고가 있음
|
||||
|
||||
@@ -514,7 +518,7 @@ npm run verify
|
||||
- 2D DWG/DXF sample과 local file load
|
||||
- 3D GLB, Draco GLB, OBJ, FBX, DAE, IFC, PLY sample render
|
||||
- 2D/3D canvas dispose와 반복 page transition
|
||||
- production subpath의 Draco·IFC decoder request
|
||||
- production subpath의 Draco·Basis KTX2·IFC decoder request와 실제 decode
|
||||
- 2D DWG/DXF와 3D GLB/PLY evidence screenshot
|
||||
|
||||
이 검증은 sample이 화면에 나타나고 runtime error가 없다는 smoke/contract 수준입니다.
|
||||
|
||||
@@ -33,6 +33,37 @@ repository에 남아 있지 않습니다. 이 저장소는 해당 binary를 stan
|
||||
위 checksum은 Google Fonts의 현재 `NanumGothic-Regular.ttf`와 동일함을
|
||||
2026-07-29에 확인했습니다.
|
||||
|
||||
## Draco decoder
|
||||
|
||||
- 역할: glTF geometry decompression
|
||||
- upstream: <https://github.com/google/draco>
|
||||
- license: Apache License 2.0
|
||||
- license text: <https://github.com/google/draco/blob/main/LICENSE>
|
||||
- distribution: 설치된 Three.js의 `DRACO_GLTF_CONFIG` import가 production build에
|
||||
version 일치 hashed JavaScript/WASM asset으로 포함합니다.
|
||||
|
||||
## Basis Universal transcoder
|
||||
|
||||
- 역할: KTX2/Basis Universal texture transcoding
|
||||
- upstream: <https://github.com/BinomialLLC/basis_universal>
|
||||
- license: Apache License 2.0
|
||||
- license text: <https://github.com/BinomialLLC/basis_universal/blob/master/LICENSE>
|
||||
- distribution: 설치된 Three.js의 `KTX2Loader` import가 production build에
|
||||
version 일치 hashed JavaScript/WASM asset으로 포함합니다.
|
||||
|
||||
## KTX2 E2E fixture
|
||||
|
||||
- 역할: production subpath에서 Basis Universal transcoder 실제 실행 검증
|
||||
- upstream:
|
||||
<https://github.com/mrdoob/three.js/blob/42f66a1620be72e497a0bb3bdc7663d21d43169a/examples/textures/ktx2/2d_etc1s.ktx2>
|
||||
- license: MIT,
|
||||
<https://github.com/mrdoob/three.js/blob/42f66a1620be72e497a0bb3bdc7663d21d43169a/LICENSE>
|
||||
- local path: `tests/fixtures/viewer3d/BasisTexture.ktx2`
|
||||
- SHA-256:
|
||||
`e56ddcc757fc73ff06bb0dac2a3533ce79c1e196ad895a3ff7dcc4d9de6b9d5d`
|
||||
- distribution: E2E가 same-origin request로만 제공하며 production artifact에는
|
||||
포함하지 않습니다.
|
||||
|
||||
## Runtime libraries
|
||||
|
||||
- Three.js — MIT, <https://github.com/mrdoob/three.js>
|
||||
|
||||
@@ -48,9 +48,10 @@ src/
|
||||
progress.ts # load progress bar
|
||||
scenes/ # per-model scene configs
|
||||
public/
|
||||
draco/ # decoder WASM (if bundled)
|
||||
basis/ # KTX2 decoder WASM (if bundled)
|
||||
web-ifc/ # single-thread IFC decoder WASM
|
||||
samples/ # 배포 demo model
|
||||
previews/ # pre-rendered WebP/WebM placeholders
|
||||
dist/assets/ # Vite hashed Draco/Basis decoder assets
|
||||
tools/
|
||||
preprocess.mjs # gltf-transform Draco+KTX2 pipeline
|
||||
prerender/ # Blender/Puppeteer turntable -> WebP
|
||||
|
||||
@@ -4,12 +4,12 @@
|
||||
|
||||
## Current state
|
||||
- **Phase:** ALL PLAN tasks complete (P0–P7). GLB + OBJ/FBX/DAE/IFC loaders (SSR+CSR) + on-screen FPS + adaptive quality (<60fps→pixelRatio degrade). Build verified (tsc 0, vite exit 0).
|
||||
- **Last action:** Phase 6+7 via dynamic workflow `multiformat-viewer` (7 agents) — multi-format dispatch (src/viewer/modelLoader.ts), web-ifc single-thread IFC, FPS overlay + adaptive ladder. Samples+previews per format.
|
||||
- **Last action:** Gitea #7 runtime artifact 경량화 — Three.js hashed decoder 통합, single-thread IFC staging, production artifact budget와 Draco/Basis/IFC subpath E2E 검증.
|
||||
- **Blockers:** none. KTX2 *production* encoding still needs KTX-Software/toktx (runtime decode verified). web-ifc multi-thread는 COOP/COEP가 필요해 배포에서 제외하고 single-thread를 사용합니다.
|
||||
- **Next:** optional in-browser per-format smoke (/verify-viewer .ifc/.fbx); OBJ/DAE geometry-only on blob: URL (accepted); document.hidden FPS guard.
|
||||
- **Next:** OBJ/DAE geometry-only on blob: URL (accepted); document.hidden FPS guard.
|
||||
|
||||
## Work log
|
||||
- 2026-07-29 — Gitea #7 runtime artifact 경량화: version 중복 5종은 incompatible/optional transitive dependency라 강제 override하지 않음. Three.js bundled hashed Draco/Basis asset으로 전환하고 `public/draco`, `public/basis`, `web-ifc-mt.wasm`, production source map 제거. artifact gate 추가. 3D dist 24,945,531→8,149,402 bytes(-67.3%), duplicate 0. Vitest 8, typecheck/build, Playwright E2E 17개 통과.
|
||||
- 2026-07-29 — Gitea #7 runtime artifact 경량화: version 중복 5종은 incompatible/optional transitive dependency라 강제 override하지 않음. Three.js bundled hashed Draco/Basis asset으로 전환하고 `public/draco`, `public/basis`, `web-ifc-mt.wasm`, production source map 제거. 2D/3D artifact gate 추가. 3D dist 24,945,531→8,149,402 bytes(-67.3%), 2D dist 4,302,677 bytes, duplicate 0. 966-byte official Three.js ETC1S KTX2 test asset으로 production subpath에서 Basis transcoder 실제 실행을 검증. Vitest 8, typecheck/build, Playwright E2E 17개 통과.
|
||||
- 2026-06-19 — KTX2 runtime path validated: toktx unavailable here (no native KTX-Software), so sourced the only single-file KTX2 GLB in Khronos — ABeautifulGame.glb (glTF-Binary-KTX-ETC1S-Draco, 11.5MB, 35 KHR_texture_basisu / 33 image/ktx2 / 17 Draco). perf-smoke load via viewer: **626ms PASS** (KTX2Loader + DRACOLoader decode confirmed). Corrected tools/preprocess.mjs doc: KTX2 CLI command is `gltf-transform etc1s|uastc` (needs KTX-Software 4.3+), not `ktx`. Samples now include KTX2+Draco variant.
|
||||
- 2026-06-18 — P4-3 prerender pipeline complete: added ThreeDViewer.rotateTo(azimuth) + `window.__viewer` exposure; wrote tools/prerender.mjs (puppeteer-core orbit capture → ffmpeg animated WebP). Fixed: MSYS path mangling of `/samples` argv; ffmpeg `libwebp_anim` broken in this build (1 frame) → switched to `-c:v libwebp -vsync vfr` (correct multi-frame). Produced public/previews/Duck.webp (24 frames, 54KB). Wired into main.ts: `?model=` sets `#preview` src to matching `/previews/<name>.webp` (onerror hides), hydration gate fades it on ready. P4-2 (3-size sample set) + P4-3 → done. Result/verify: `npm run build` exits 0; ANMF=24 confirmed.
|
||||
- 2026-06-18 — Asset pipeline validated + P5-3 perf smoke: fixed tools/preprocess.mjs (textureCompress ktx2 → webp; registered draco3d.encoder dependency + EXTTextureWebP). Ran on Duck.glb → 117.7KiB→31.2KiB (73.4% reduction, Draco+webp). Added `window` `hmw:ready` event in ThreeDViewer.onLoaded. Wrote tools/perf-smoke.mjs (puppeteer-core + system Chrome headless). Ran against preview: Box 102ms, Duck 94ms, Duck.optimized(Draco) 141ms, Avocado 271ms — all < 3000ms PASS. Draco runtime decoder path confirmed. P5-3 → done.
|
||||
|
||||
@@ -96,7 +96,7 @@
|
||||
<h2>3. 기술 스택 (확정)</h2>
|
||||
<table>
|
||||
<tr><th>영역</th><th>선택</th><th>비고</th></tr>
|
||||
<tr><td>렌더러</td><td>three.js WebGLRenderer (r0.169)</td><td>WebGPU는 차후 옵션</td></tr>
|
||||
<tr><td>렌더러</td><td>three.js WebGLRenderer (r185)</td><td>WebGPU는 차후 옵션</td></tr>
|
||||
<tr><td>로더(기준)</td><td>GLTFLoader + DRACOLoader + KTX2Loader</td><td><strong>각 1개 싱글톤</strong> — 다중 인스턴스 크래시(#22445)</td></tr>
|
||||
<tr><td>로더(확장)</td><td>OBJ / FBX / Collada — three example loaders</td><td>지연 <code>import()</code> → 별도 Vite 청크(초기 번들 미증가)</td></tr>
|
||||
<tr><td>로더(IFC)</td><td>web-ifc <code>IfcAPI</code> (v0.0.77)</td><td>단일스레드 <code>web-ifc.wasm</code> 자가호스팅(<code>/web-ifc/</code>), <code>SetWasmPath(...,true)</code></td></tr>
|
||||
@@ -123,8 +123,9 @@
|
||||
fps.ts # ★ 화면 FPS 오버레이 (EMA, 색상코딩)
|
||||
public/
|
||||
web-ifc/ # ★ web-ifc.wasm (단일스레드, 자동 복사)
|
||||
samples/ # Box/Duck/Avocado/Duck.optimized + Cube.obj/dae/fbx/ifc
|
||||
samples/ # 배포 demo: Box/Duck/Avocado/Duck.optimized + Cube.obj/dae/fbx/ifc
|
||||
previews/ # Duck.webp + Cube.{obj,dae,fbx,ifc}.webp (24프레임 360°)
|
||||
dist/assets/ # ★ Vite hashed Draco/Basis decoder JS/WASM
|
||||
tools/
|
||||
preprocess.mjs # gltf-transform Draco+WebP 압축 (GLB 전용)
|
||||
copy-decoders.mjs # postinstall: single-thread web-ifc WASM 복사
|
||||
@@ -185,11 +186,11 @@ tools/
|
||||
<h2>8. 현재 구현 상태</h2>
|
||||
<div class="card">
|
||||
<ul>
|
||||
<li><strong>빌드:</strong> <code>tsc --noEmit</code> 클린, <code>npm run build</code> 통과 (vite 5.4.21, 11.92s). 청크: 앱 138KB(gz 47) · three vendor 533KB(gz 136) · web-ifc 3.5MB(gz 398, <em>지연 로드</em>) · OBJ 8.8 / Collada 41 / FBX 48KB.</li>
|
||||
<li><strong>빌드:</strong> <code>tsc --noEmit</code> 클린, Vite 8 production build 통과. 3D artifact 27개, 8,149,402 bytes, 50KB 이상 중복 0개.</li>
|
||||
<li><strong>포맷별 인브라우저 로드(헤드리스 Chrome):</strong> GLB 229ms · OBJ 173ms · FBX 158ms · DAE 178ms · <strong>IFC 463ms</strong> · Duck.optimized 182ms — 전체 <3000ms PASS.</li>
|
||||
<li><strong>적응형 품질:</strong> 60fps 미만 지속 시 pixelRatio 3단계 강등 실측 확인(위 7절).</li>
|
||||
<li><strong>샘플:</strong> Box/Duck/Avocado/Duck.optimized(GLB) + Cube.obj(793B)/Cube.dae(2.1K)/Cube.fbx(16.2K)/Cube.ifc(2.3K). 각 24프레임 360° WebP 프리뷰.</li>
|
||||
<li><strong>KTX2 런타임 경로:</strong> KTX2Loader+DRACOLoader 동시 디코드 확인(ABeautifulGame, 626ms). 인코딩은 toktx 필요(미설치).</li>
|
||||
<li><strong>KTX2 런타임 경로:</strong> official Three.js ETC1S KTX2 fixture를 production subpath에서 로드하고 hashed Basis transcoder WASM request와 model ready를 E2E로 확인. 인코딩은 toktx 필요(미설치).</li>
|
||||
<li><strong>하드닝:</strong> WebGL 미지원 가드, 로드 실패 메시지(<code>#status</code>), DnD 검증 피드백, IFC wasm 메모리 해제 — reviewer 0 critical.</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
@@ -25,6 +25,13 @@
|
||||
원출처가 확인되지 않은 fixture는 내부 회귀 test에만 사용합니다. 공개 배포 전에
|
||||
소유권이 확인된 fixture로 교체하거나 명시적 재배포 승인을 기록해야 합니다.
|
||||
|
||||
## 3D test fixture
|
||||
|
||||
| Fixture | SHA-256 | 상태 |
|
||||
|---|---|---|
|
||||
| `BasisTexture.ktx2` | `e56ddcc757fc73ff06bb0dac2a3533ce79c1e196ad895a3ff7dcc4d9de6b9d5d` | Three.js commit `42f66a1620be72e497a0bb3bdc7663d21d43169a`의 `examples/textures/ktx2/2d_etc1s.ktx2`, MIT. E2E에서만 제공하며 production artifact에는 미포함 |
|
||||
| `BasisTexture.gltf` | repository text fixture | 위 KTX2를 적용한 최소 triangle을 이 저장소에서 작성. E2E 전용 |
|
||||
|
||||
## Policy
|
||||
|
||||
- source와 binary는 exact commit 또는 SHA-256으로 고정합니다.
|
||||
|
||||
@@ -1,16 +1,44 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { readFileSync } from 'node:fs';
|
||||
|
||||
test('viewer3d resolves hashed Draco and staged IFC decoders under a production subpath', async ({
|
||||
const basisFixture = readFileSync(
|
||||
new URL('../fixtures/viewer3d/BasisTexture.gltf', import.meta.url),
|
||||
);
|
||||
const basisTexture = readFileSync(
|
||||
new URL('../fixtures/viewer3d/BasisTexture.ktx2', import.meta.url),
|
||||
);
|
||||
|
||||
test('viewer3d resolves bundled model decoders under a production subpath', async ({
|
||||
page,
|
||||
}) => {
|
||||
const failedRequests: string[] = [];
|
||||
const responseUrls: string[] = [];
|
||||
page.on('requestfailed', (request) => failedRequests.push(request.url()));
|
||||
page.on('response', (response) => responseUrls.push(response.url()));
|
||||
await page.route(
|
||||
/^https?:\/\/[^/?]+\/viewer-3d\/e2e-fixtures\/BasisTexture\.gltf(?:\?.*)?$/,
|
||||
(route) =>
|
||||
route.fulfill({
|
||||
body: basisFixture,
|
||||
contentType: 'model/gltf+json',
|
||||
}),
|
||||
);
|
||||
await page.route(
|
||||
/^https?:\/\/[^/?]+\/viewer-3d\/e2e-fixtures\/BasisTexture\.ktx2(?:\?.*)?$/,
|
||||
(route) =>
|
||||
route.fulfill({
|
||||
body: basisTexture,
|
||||
contentType: 'image/ktx2',
|
||||
}),
|
||||
);
|
||||
|
||||
for (const fixture of ['Duck.optimized.glb', 'Cube.ifc']) {
|
||||
for (const fixture of [
|
||||
'samples/Duck.optimized.glb',
|
||||
'e2e-fixtures/BasisTexture.gltf',
|
||||
'samples/Cube.ifc',
|
||||
]) {
|
||||
await page.goto(
|
||||
`http://127.0.0.1:43174/viewer-3d/?model=/viewer-3d/samples/${fixture}`,
|
||||
`http://127.0.0.1:43174/viewer-3d/?model=/viewer-3d/${fixture}`,
|
||||
);
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
@@ -31,6 +59,11 @@ test('viewer3d resolves hashed Draco and staged IFC decoders under a production
|
||||
/\/viewer-3d\/assets\/draco_decoder-.+\.wasm$/.test(new URL(url).pathname),
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
responseUrls.some((url) =>
|
||||
/\/viewer-3d\/assets\/basis_transcoder-.+\.wasm$/.test(new URL(url).pathname),
|
||||
),
|
||||
).toBe(true);
|
||||
expect(responseUrls.some((url) => url.endsWith('/viewer-3d/web-ifc/web-ifc.wasm'))).toBe(true);
|
||||
expect(failedRequests).toEqual([]);
|
||||
});
|
||||
|
||||
+107
@@ -0,0 +1,107 @@
|
||||
{
|
||||
"asset": {
|
||||
"version": "2.0",
|
||||
"generator": "hmwebviewer KTX2 E2E fixture"
|
||||
},
|
||||
"extensionsUsed": ["KHR_texture_basisu"],
|
||||
"extensionsRequired": ["KHR_texture_basisu"],
|
||||
"scene": 0,
|
||||
"scenes": [
|
||||
{
|
||||
"nodes": [0]
|
||||
}
|
||||
],
|
||||
"nodes": [
|
||||
{
|
||||
"mesh": 0
|
||||
}
|
||||
],
|
||||
"meshes": [
|
||||
{
|
||||
"primitives": [
|
||||
{
|
||||
"attributes": {
|
||||
"POSITION": 0,
|
||||
"TEXCOORD_0": 1
|
||||
},
|
||||
"indices": 2,
|
||||
"material": 0
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"materials": [
|
||||
{
|
||||
"pbrMetallicRoughness": {
|
||||
"baseColorTexture": {
|
||||
"index": 0
|
||||
},
|
||||
"metallicFactor": 0,
|
||||
"roughnessFactor": 1
|
||||
}
|
||||
}
|
||||
],
|
||||
"textures": [
|
||||
{
|
||||
"extensions": {
|
||||
"KHR_texture_basisu": {
|
||||
"source": 0
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"images": [
|
||||
{
|
||||
"uri": "BasisTexture.ktx2",
|
||||
"mimeType": "image/ktx2"
|
||||
}
|
||||
],
|
||||
"buffers": [
|
||||
{
|
||||
"uri": "data:application/octet-stream;base64,AACAvwAAgL8AAAAAAACAPwAAgL8AAAAAAAAAAAAAgD8AAAAAAAAAAAAAAAAAAIA/AAAAAAAAAD8AAIA/AAABAAIA",
|
||||
"byteLength": 66
|
||||
}
|
||||
],
|
||||
"bufferViews": [
|
||||
{
|
||||
"buffer": 0,
|
||||
"byteOffset": 0,
|
||||
"byteLength": 36,
|
||||
"target": 34962
|
||||
},
|
||||
{
|
||||
"buffer": 0,
|
||||
"byteOffset": 36,
|
||||
"byteLength": 24,
|
||||
"target": 34962
|
||||
},
|
||||
{
|
||||
"buffer": 0,
|
||||
"byteOffset": 60,
|
||||
"byteLength": 6,
|
||||
"target": 34963
|
||||
}
|
||||
],
|
||||
"accessors": [
|
||||
{
|
||||
"bufferView": 0,
|
||||
"componentType": 5126,
|
||||
"count": 3,
|
||||
"type": "VEC3",
|
||||
"min": [-1, -1, 0],
|
||||
"max": [1, 1, 0]
|
||||
},
|
||||
{
|
||||
"bufferView": 1,
|
||||
"componentType": 5126,
|
||||
"count": 3,
|
||||
"type": "VEC2"
|
||||
},
|
||||
{
|
||||
"bufferView": 2,
|
||||
"componentType": 5123,
|
||||
"count": 3,
|
||||
"type": "SCALAR"
|
||||
}
|
||||
]
|
||||
}
|
||||
BIN
Binary file not shown.
@@ -4,8 +4,24 @@ import { dirname, relative, resolve, sep } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const projectRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const distRoot = resolve(projectRoot, 'apps/viewer-3d/dist');
|
||||
const maxDistBytes = 9_000_000;
|
||||
const distSpecs = [
|
||||
{
|
||||
name: '2D',
|
||||
root: resolve(projectRoot, 'apps/viewer-2d-sample/dist'),
|
||||
maxBytes: 4_500_000,
|
||||
required: [/^assets\/acadrust_dwg_bg-.+\.wasm$/],
|
||||
},
|
||||
{
|
||||
name: '3D',
|
||||
root: resolve(projectRoot, 'apps/viewer-3d/dist'),
|
||||
maxBytes: 9_000_000,
|
||||
required: [
|
||||
/^assets\/draco_decoder-.+\.wasm$/,
|
||||
/^assets\/basis_transcoder-.+\.wasm$/,
|
||||
/^web-ifc\/web-ifc\.wasm$/,
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
function listFiles(directory) {
|
||||
return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => {
|
||||
@@ -14,36 +30,41 @@ function listFiles(directory) {
|
||||
});
|
||||
}
|
||||
|
||||
function toRelative(path) {
|
||||
return relative(distRoot, path).split(sep).join('/');
|
||||
function toRelative(root, path) {
|
||||
return relative(root, path).split(sep).join('/');
|
||||
}
|
||||
|
||||
if (!existsSync(distRoot)) {
|
||||
throw new Error('3D production dist가 없습니다. 먼저 npm run build를 실행하세요.');
|
||||
}
|
||||
|
||||
const files = listFiles(distRoot);
|
||||
const relativeFiles = files.map(toRelative);
|
||||
const totalBytes = files.reduce((sum, path) => sum + statSync(path).size, 0);
|
||||
const failures = [];
|
||||
const results = [];
|
||||
|
||||
for (const spec of distSpecs) {
|
||||
if (!existsSync(spec.root)) {
|
||||
failures.push(`${spec.name} production dist가 없습니다. 먼저 npm run build를 실행하세요.`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const files = listFiles(spec.root);
|
||||
const relativeFiles = files.map((path) => toRelative(spec.root, path));
|
||||
const totalBytes = files.reduce((sum, path) => sum + statSync(path).size, 0);
|
||||
|
||||
for (const path of relativeFiles) {
|
||||
if (path.endsWith('.map')) failures.push(`production source map 포함: ${path}`);
|
||||
if (path === 'web-ifc/web-ifc-mt.wasm') {
|
||||
if (path.endsWith('.map')) {
|
||||
failures.push(`${spec.name} production source map 포함: ${path}`);
|
||||
}
|
||||
if (spec.name === '3D' && path === 'web-ifc/web-ifc-mt.wasm') {
|
||||
failures.push(`미사용 multi-thread IFC WASM 포함: ${path}`);
|
||||
}
|
||||
if (path.startsWith('draco/') || path.startsWith('basis/')) {
|
||||
if (
|
||||
spec.name === '3D' &&
|
||||
(path.startsWith('draco/') || path.startsWith('basis/'))
|
||||
) {
|
||||
failures.push(`Three.js bundled decoder의 public 중복 포함: ${path}`);
|
||||
}
|
||||
}
|
||||
|
||||
for (const pattern of [
|
||||
/^assets\/draco_decoder-.+\.wasm$/,
|
||||
/^assets\/basis_transcoder-.+\.wasm$/,
|
||||
/^web-ifc\/web-ifc\.wasm$/,
|
||||
]) {
|
||||
for (const pattern of spec.required) {
|
||||
if (!relativeFiles.some((path) => pattern.test(path))) {
|
||||
failures.push(`필수 runtime artifact 누락: ${pattern}`);
|
||||
failures.push(`${spec.name} 필수 runtime artifact 누락: ${pattern}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,24 +73,38 @@ for (const path of files) {
|
||||
if (statSync(path).size < 50_000) continue;
|
||||
const hash = createHash('sha256').update(readFileSync(path)).digest('hex');
|
||||
const paths = hashes.get(hash) ?? [];
|
||||
paths.push(toRelative(path));
|
||||
paths.push(toRelative(spec.root, path));
|
||||
hashes.set(hash, paths);
|
||||
}
|
||||
for (const paths of hashes.values()) {
|
||||
if (paths.length > 1) failures.push(`50 kB 이상 중복 artifact: ${paths.join(', ')}`);
|
||||
const duplicates = [...hashes.values()].filter((paths) => paths.length > 1);
|
||||
for (const paths of duplicates) {
|
||||
failures.push(`${spec.name} 50 kB 이상 중복 artifact: ${paths.join(', ')}`);
|
||||
}
|
||||
|
||||
if (totalBytes > maxDistBytes) {
|
||||
if (totalBytes > spec.maxBytes) {
|
||||
failures.push(
|
||||
`3D production dist budget 초과: ${totalBytes} bytes > ${maxDistBytes} bytes`,
|
||||
`${spec.name} production dist budget 초과: ${totalBytes} bytes > ${spec.maxBytes} bytes`,
|
||||
);
|
||||
}
|
||||
|
||||
results.push({
|
||||
name: spec.name,
|
||||
files: relativeFiles.length,
|
||||
totalBytes,
|
||||
duplicates: duplicates.length,
|
||||
});
|
||||
}
|
||||
|
||||
if (failures.length > 0) {
|
||||
console.error(failures.map((failure) => `- ${failure}`).join('\n'));
|
||||
process.exitCode = 1;
|
||||
} else {
|
||||
console.log(
|
||||
`[artifact-check] ${relativeFiles.length} files, ${totalBytes} bytes, duplicate 0`,
|
||||
results
|
||||
.map(
|
||||
({ name, files, totalBytes, duplicates }) =>
|
||||
`[artifact-check] ${name}: ${files} files, ${totalBytes} bytes, duplicate ${duplicates}`,
|
||||
)
|
||||
.join('\n'),
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user