@things-factory/attachment-base 10.0.19 → 10.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -36,8 +36,8 @@ const VIEWER_HTML = `<!doctype html>
36
36
  <script type="importmap">
37
37
  {
38
38
  "imports": {
39
- "three": "https://cdn.jsdelivr.net/npm/three@0.183.2/build/three.module.js",
40
- "three/addons/": "https://cdn.jsdelivr.net/npm/three@0.183.2/examples/jsm/"
39
+ "three": "https://cdn.jsdelivr.net/npm/three@0.185.1/build/three.module.js",
40
+ "three/addons/": "https://cdn.jsdelivr.net/npm/three@0.185.1/examples/jsm/"
41
41
  }
42
42
  }
43
43
  </script>
@@ -1 +1 @@
1
- {"version":3,"file":"gltf-thumbnail.js","sourceRoot":"","sources":["../../../server/service/attachment/gltf-thumbnail.ts"],"names":[],"mappings":"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","sourcesContent":["import { logger } from '@things-factory/env'\nimport { getOrCreateHeadlessPool } from '@things-factory/shell'\n\n/** 첨부파일 썸네일 전용 headless pool — 보드 pool 과 격리 */\nconst POOL_NAME = 'attachment-thumbnail'\n\nconst W = 400\nconst H = 400\n// AWS EC2 일반 인스턴스 (GPU 없음) 환경에서는 swiftshader software 렌더링이라\n// Three.js + GLTFLoader 가 15s 안에 끝나기 어렵다. 첫 호출의 cold start (browser\n// launch + asset fetch + parse + render) 까지 흡수할 수 있게 30s 로 상향.\nconst TIMEOUT_MS = 30000\n\n/**\n * 1×1 흰색 PNG — 외부 텍스처 fetch 실패 시 placeholder 로 응답하기 위한 바이트.\n * GLTFLoader 는 텍스처 reference 가 부재하면 promise 를 reject 하므로, \"텍스처\n * 못 가져오면 그냥 흰색으로라도 진행\" 으로 동작시키면 모델 형태만이라도 썸네일에\n * 남는다 (\"썸네일 미생성\" 보다 명백히 우월).\n */\nconst TINY_PNG = Buffer.from(\n 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==',\n 'base64'\n)\n\n/**\n * 약속: 아래 virtual URL 들은 request interception 으로만 의미를 가짐.\n * - /__viewer__ : 뷰어 HTML\n * - /__gltf__ : GLTF 바이트 (binary) 또는 JSON (text)\n * puppeteer page 가 내부 네비게이션/fetch 시 이 URL 들을 만나면 mock response.\n */\nconst VIEWER_URL = 'http://attachment-thumbnail.local/__viewer__'\nconst GLTF_URL = 'http://attachment-thumbnail.local/__gltf__'\n\nconst VIEWER_HTML = `<!doctype html>\n<html><head>\n<meta charset=\"utf-8\">\n<style>\n html, body { margin: 0; padding: 0; background: #f5f5f5; }\n canvas { display: block; }\n</style>\n<script type=\"importmap\">\n{\n \"imports\": {\n \"three\": \"https://cdn.jsdelivr.net/npm/three@0.183.2/build/three.module.js\",\n \"three/addons/\": \"https://cdn.jsdelivr.net/npm/three@0.183.2/examples/jsm/\"\n }\n}\n</script>\n</head>\n<body>\n<script type=\"module\">\nimport * as THREE from 'three'\nimport { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js'\nimport { DRACOLoader } from 'three/addons/loaders/DRACOLoader.js'\nimport { KTX2Loader } from 'three/addons/loaders/KTX2Loader.js'\nimport { MeshoptDecoder } from 'three/addons/libs/meshopt_decoder.module.js'\n\nconst W = ${W}, H = ${H}\nwindow.__gltfReady = false\nwindow.__gltfError = null\n\n// AWS EC2 일반 인스턴스 (GPU 없음) 환경에서는 swiftshader software 렌더링이라\n// antialias / 높은 pixelRatio 가 매우 비싸다. 정적 썸네일 용도라 antialias 끄고\n// pixelRatio 1 로 고정.\nconst renderer = new THREE.WebGLRenderer({ alpha: true, antialias: false })\nrenderer.setPixelRatio(1)\nrenderer.setSize(W, H)\nrenderer.setClearColor(0xf5f5f5, 1)\nrenderer.outputColorSpace = THREE.SRGBColorSpace\ndocument.body.appendChild(renderer.domElement)\n\nconst scene = new THREE.Scene()\nconst camera = new THREE.PerspectiveCamera(35, W / H, 0.1, 10000)\n\n// 기본 조명 — 모델 세부 손실 최소화\nscene.add(new THREE.AmbientLight(0xffffff, 0.6))\nconst hemi = new THREE.HemisphereLight(0xffffff, 0x444444, 0.8)\nscene.add(hemi)\nconst dir = new THREE.DirectionalLight(0xffffff, 1.2)\ndir.position.set(5, 10, 7.5)\nscene.add(dir)\n\n;(async () => {\n try {\n const res = await fetch('${GLTF_URL}')\n if (!res.ok) throw new Error('fetch failed: ' + res.status)\n const ab = await res.arrayBuffer()\n\n const loader = new GLTFLoader()\n\n // DRACO 압축 메쉬 지원 (필요한 GLB 만 로드 시 decoder fetch)\n const draco = new DRACOLoader()\n draco.setDecoderPath('https://cdn.jsdelivr.net/npm/three@0.183.2/examples/jsm/libs/draco/')\n loader.setDRACOLoader(draco)\n\n // KTX2 (BasisU) 압축 텍스처 지원\n const ktx2 = new KTX2Loader()\n ktx2.setTranscoderPath('https://cdn.jsdelivr.net/npm/three@0.183.2/examples/jsm/libs/basis/')\n ktx2.detectSupport(renderer)\n loader.setKTX2Loader(ktx2)\n\n // Meshopt 압축 지원\n loader.setMeshoptDecoder(MeshoptDecoder)\n\n const gltf = await loader.parseAsync(ab, '')\n scene.add(gltf.scene)\n\n // Bounding box 기반 카메라 auto-fit\n const box = new THREE.Box3().setFromObject(gltf.scene)\n const size = box.getSize(new THREE.Vector3())\n const center = box.getCenter(new THREE.Vector3())\n const maxDim = Math.max(size.x, size.y, size.z) || 1\n const fov = camera.fov * (Math.PI / 180)\n const distance = (maxDim / 2) / Math.tan(fov / 2) * 2.0\n\n // 45도 정면-위쪽 뷰포인트 — 3D 모델 특징이 잘 드러나는 각도\n const dirVec = new THREE.Vector3(1, 0.7, 1).normalize()\n camera.position.copy(center).add(dirVec.multiplyScalar(distance))\n camera.near = Math.max(distance / 100, 0.01)\n camera.far = distance * 10\n camera.updateProjectionMatrix()\n camera.lookAt(center)\n\n // 단일 프레임 렌더 (정적 썸네일이라 두 번 render + rAF 불필요)\n renderer.render(scene, camera)\n window.__gltfReady = true\n } catch (e) {\n window.__gltfError = e?.message || String(e)\n }\n})()\n</script>\n</body></html>`\n\n/**\n * GLTF/GLB 바이트로부터 정적 JPEG 썸네일 data URI 생성.\n * headless Chrome 에 Three.js 로 렌더링된 단일 프레임을 캡처한다.\n * 지원 타입: model/gltf-binary (.glb), model/gltf+json (.gltf)\n */\nexport async function generateGltfThumbnail(\n contents: Buffer | undefined | null,\n mimetype: string | undefined\n): Promise<string | undefined> {\n if (!contents) return\n if (mimetype !== 'model/gltf-binary' && mimetype !== 'model/gltf+json') return\n\n let pool: any\n try {\n pool = getOrCreateHeadlessPool(POOL_NAME, { min: 0, max: 2 })\n } catch (e) {\n logger.warn(`[attachment] gltf thumbnail — headless pool unavailable: ${(e as Error).message}`)\n return\n }\n\n const browser = await pool.acquire()\n if (!browser) return\n\n const page = await browser.newPage()\n try {\n await page.setViewport({ width: W, height: H, deviceScaleFactor: 1 })\n await page.setRequestInterception(true)\n\n page.on('request', async (req: any) => {\n const url = req.url()\n if (url === VIEWER_URL) {\n await req.respond({ status: 200, contentType: 'text/html; charset=utf-8', body: VIEWER_HTML })\n return\n }\n if (url === GLTF_URL) {\n await req.respond({\n status: 200,\n contentType: mimetype === 'model/gltf+json' ? 'model/gltf+json' : 'application/octet-stream',\n body: contents\n })\n return\n }\n // 외부 텍스처 (.png/.jpg/.webp/.gif/.ktx2/.basis) 는 1x1 placeholder 로\n // 응답한다. GLB 는 보통 self-contained 라 외부 fetch 가 거의 없지만,\n // gltf+json 은 외부 텍스처 reference 가 흔하고 그 fetch 가 실패하면\n // GLTFLoader 가 promise reject → 썸네일 미생성으로 이어졌다. 텍스처\n // 누락은 무시하고 모델 형태만이라도 캡처하도록 placeholder 응답.\n // (.bin 외부 buffer 와 wasm decoder 는 본질적 한계 — 그대로 통과.)\n if (/\\.(png|jpe?g|webp|gif|ktx2|basis)(\\?|$)/i.test(url)) {\n await req.respond({ status: 200, contentType: 'image/png', body: TINY_PNG })\n return\n }\n req.continue()\n })\n\n await page.goto(VIEWER_URL, { waitUntil: 'domcontentloaded' })\n\n await page.waitForFunction('window.__gltfReady === true || window.__gltfError', {\n timeout: TIMEOUT_MS\n })\n\n const err = await page.evaluate('window.__gltfError')\n if (err) {\n logger.warn(`[attachment] gltf thumbnail error: ${err}`)\n return\n }\n\n // puppeteer 신버전은 Uint8Array 를 반환하는데 Uint8Array.toString('base64') 는 encoding 인자를\n // 무시해서 decimal comma string 이 나온다. encoding: 'base64' 를 직접 요청해서 문자열을 받는다.\n const b64 = (await page.screenshot({ type: 'jpeg', quality: 70, encoding: 'base64' })) as string\n return `data:image/jpeg;base64,${b64}`\n } catch (e) {\n logger.warn(`[attachment] gltf thumbnail failed: ${(e as Error).message}`)\n return\n } finally {\n try { await page.close() } catch {}\n try { await pool.release(browser) } catch {}\n }\n}\n"]}
1
+ {"version":3,"file":"gltf-thumbnail.js","sourceRoot":"","sources":["../../../server/service/attachment/gltf-thumbnail.ts"],"names":[],"mappings":"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","sourcesContent":["import { logger } from '@things-factory/env'\nimport { getOrCreateHeadlessPool } from '@things-factory/shell'\n\n/** 첨부파일 썸네일 전용 headless pool — 보드 pool 과 격리 */\nconst POOL_NAME = 'attachment-thumbnail'\n\nconst W = 400\nconst H = 400\n// AWS EC2 일반 인스턴스 (GPU 없음) 환경에서는 swiftshader software 렌더링이라\n// Three.js + GLTFLoader 가 15s 안에 끝나기 어렵다. 첫 호출의 cold start (browser\n// launch + asset fetch + parse + render) 까지 흡수할 수 있게 30s 로 상향.\nconst TIMEOUT_MS = 30000\n\n/**\n * 1×1 흰색 PNG — 외부 텍스처 fetch 실패 시 placeholder 로 응답하기 위한 바이트.\n * GLTFLoader 는 텍스처 reference 가 부재하면 promise 를 reject 하므로, \"텍스처\n * 못 가져오면 그냥 흰색으로라도 진행\" 으로 동작시키면 모델 형태만이라도 썸네일에\n * 남는다 (\"썸네일 미생성\" 보다 명백히 우월).\n */\nconst TINY_PNG = Buffer.from(\n 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==',\n 'base64'\n)\n\n/**\n * 약속: 아래 virtual URL 들은 request interception 으로만 의미를 가짐.\n * - /__viewer__ : 뷰어 HTML\n * - /__gltf__ : GLTF 바이트 (binary) 또는 JSON (text)\n * puppeteer page 가 내부 네비게이션/fetch 시 이 URL 들을 만나면 mock response.\n */\nconst VIEWER_URL = 'http://attachment-thumbnail.local/__viewer__'\nconst GLTF_URL = 'http://attachment-thumbnail.local/__gltf__'\n\nconst VIEWER_HTML = `<!doctype html>\n<html><head>\n<meta charset=\"utf-8\">\n<style>\n html, body { margin: 0; padding: 0; background: #f5f5f5; }\n canvas { display: block; }\n</style>\n<script type=\"importmap\">\n{\n \"imports\": {\n \"three\": \"https://cdn.jsdelivr.net/npm/three@0.185.1/build/three.module.js\",\n \"three/addons/\": \"https://cdn.jsdelivr.net/npm/three@0.185.1/examples/jsm/\"\n }\n}\n</script>\n</head>\n<body>\n<script type=\"module\">\nimport * as THREE from 'three'\nimport { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js'\nimport { DRACOLoader } from 'three/addons/loaders/DRACOLoader.js'\nimport { KTX2Loader } from 'three/addons/loaders/KTX2Loader.js'\nimport { MeshoptDecoder } from 'three/addons/libs/meshopt_decoder.module.js'\n\nconst W = ${W}, H = ${H}\nwindow.__gltfReady = false\nwindow.__gltfError = null\n\n// AWS EC2 일반 인스턴스 (GPU 없음) 환경에서는 swiftshader software 렌더링이라\n// antialias / 높은 pixelRatio 가 매우 비싸다. 정적 썸네일 용도라 antialias 끄고\n// pixelRatio 1 로 고정.\nconst renderer = new THREE.WebGLRenderer({ alpha: true, antialias: false })\nrenderer.setPixelRatio(1)\nrenderer.setSize(W, H)\nrenderer.setClearColor(0xf5f5f5, 1)\nrenderer.outputColorSpace = THREE.SRGBColorSpace\ndocument.body.appendChild(renderer.domElement)\n\nconst scene = new THREE.Scene()\nconst camera = new THREE.PerspectiveCamera(35, W / H, 0.1, 10000)\n\n// 기본 조명 — 모델 세부 손실 최소화\nscene.add(new THREE.AmbientLight(0xffffff, 0.6))\nconst hemi = new THREE.HemisphereLight(0xffffff, 0x444444, 0.8)\nscene.add(hemi)\nconst dir = new THREE.DirectionalLight(0xffffff, 1.2)\ndir.position.set(5, 10, 7.5)\nscene.add(dir)\n\n;(async () => {\n try {\n const res = await fetch('${GLTF_URL}')\n if (!res.ok) throw new Error('fetch failed: ' + res.status)\n const ab = await res.arrayBuffer()\n\n const loader = new GLTFLoader()\n\n // DRACO 압축 메쉬 지원 (필요한 GLB 만 로드 시 decoder fetch)\n const draco = new DRACOLoader()\n draco.setDecoderPath('https://cdn.jsdelivr.net/npm/three@0.183.2/examples/jsm/libs/draco/')\n loader.setDRACOLoader(draco)\n\n // KTX2 (BasisU) 압축 텍스처 지원\n const ktx2 = new KTX2Loader()\n ktx2.setTranscoderPath('https://cdn.jsdelivr.net/npm/three@0.183.2/examples/jsm/libs/basis/')\n ktx2.detectSupport(renderer)\n loader.setKTX2Loader(ktx2)\n\n // Meshopt 압축 지원\n loader.setMeshoptDecoder(MeshoptDecoder)\n\n const gltf = await loader.parseAsync(ab, '')\n scene.add(gltf.scene)\n\n // Bounding box 기반 카메라 auto-fit\n const box = new THREE.Box3().setFromObject(gltf.scene)\n const size = box.getSize(new THREE.Vector3())\n const center = box.getCenter(new THREE.Vector3())\n const maxDim = Math.max(size.x, size.y, size.z) || 1\n const fov = camera.fov * (Math.PI / 180)\n const distance = (maxDim / 2) / Math.tan(fov / 2) * 2.0\n\n // 45도 정면-위쪽 뷰포인트 — 3D 모델 특징이 잘 드러나는 각도\n const dirVec = new THREE.Vector3(1, 0.7, 1).normalize()\n camera.position.copy(center).add(dirVec.multiplyScalar(distance))\n camera.near = Math.max(distance / 100, 0.01)\n camera.far = distance * 10\n camera.updateProjectionMatrix()\n camera.lookAt(center)\n\n // 단일 프레임 렌더 (정적 썸네일이라 두 번 render + rAF 불필요)\n renderer.render(scene, camera)\n window.__gltfReady = true\n } catch (e) {\n window.__gltfError = e?.message || String(e)\n }\n})()\n</script>\n</body></html>`\n\n/**\n * GLTF/GLB 바이트로부터 정적 JPEG 썸네일 data URI 생성.\n * headless Chrome 에 Three.js 로 렌더링된 단일 프레임을 캡처한다.\n * 지원 타입: model/gltf-binary (.glb), model/gltf+json (.gltf)\n */\nexport async function generateGltfThumbnail(\n contents: Buffer | undefined | null,\n mimetype: string | undefined\n): Promise<string | undefined> {\n if (!contents) return\n if (mimetype !== 'model/gltf-binary' && mimetype !== 'model/gltf+json') return\n\n let pool: any\n try {\n pool = getOrCreateHeadlessPool(POOL_NAME, { min: 0, max: 2 })\n } catch (e) {\n logger.warn(`[attachment] gltf thumbnail — headless pool unavailable: ${(e as Error).message}`)\n return\n }\n\n const browser = await pool.acquire()\n if (!browser) return\n\n const page = await browser.newPage()\n try {\n await page.setViewport({ width: W, height: H, deviceScaleFactor: 1 })\n await page.setRequestInterception(true)\n\n page.on('request', async (req: any) => {\n const url = req.url()\n if (url === VIEWER_URL) {\n await req.respond({ status: 200, contentType: 'text/html; charset=utf-8', body: VIEWER_HTML })\n return\n }\n if (url === GLTF_URL) {\n await req.respond({\n status: 200,\n contentType: mimetype === 'model/gltf+json' ? 'model/gltf+json' : 'application/octet-stream',\n body: contents\n })\n return\n }\n // 외부 텍스처 (.png/.jpg/.webp/.gif/.ktx2/.basis) 는 1x1 placeholder 로\n // 응답한다. GLB 는 보통 self-contained 라 외부 fetch 가 거의 없지만,\n // gltf+json 은 외부 텍스처 reference 가 흔하고 그 fetch 가 실패하면\n // GLTFLoader 가 promise reject → 썸네일 미생성으로 이어졌다. 텍스처\n // 누락은 무시하고 모델 형태만이라도 캡처하도록 placeholder 응답.\n // (.bin 외부 buffer 와 wasm decoder 는 본질적 한계 — 그대로 통과.)\n if (/\\.(png|jpe?g|webp|gif|ktx2|basis)(\\?|$)/i.test(url)) {\n await req.respond({ status: 200, contentType: 'image/png', body: TINY_PNG })\n return\n }\n req.continue()\n })\n\n await page.goto(VIEWER_URL, { waitUntil: 'domcontentloaded' })\n\n await page.waitForFunction('window.__gltfReady === true || window.__gltfError', {\n timeout: TIMEOUT_MS\n })\n\n const err = await page.evaluate('window.__gltfError')\n if (err) {\n logger.warn(`[attachment] gltf thumbnail error: ${err}`)\n return\n }\n\n // puppeteer 신버전은 Uint8Array 를 반환하는데 Uint8Array.toString('base64') 는 encoding 인자를\n // 무시해서 decimal comma string 이 나온다. encoding: 'base64' 를 직접 요청해서 문자열을 받는다.\n const b64 = (await page.screenshot({ type: 'jpeg', quality: 70, encoding: 'base64' })) as string\n return `data:image/jpeg;base64,${b64}`\n } catch (e) {\n logger.warn(`[attachment] gltf thumbnail failed: ${(e as Error).message}`)\n return\n } finally {\n try { await page.close() } catch {}\n try { await pool.release(browser) } catch {}\n }\n}\n"]}