Spaces:
Running
Running
| <html lang="en"> | |
| <head> | |
| <meta charset="utf-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1"> | |
| <title>DrawFace Live โ Face Mirroring</title> | |
| <!-- ๋ฉ์ธ = ๋ฏธ๋ฌ๋ง ์คํ๋์ค: ๋ค์ด๊ฐ์๋ง์ ์์ฐ. ์บ๋ฆญํฐ ์ ํ(์๊ณ ์ 3D ๊ธฐ๋ณธ / ์๋ 2D), | |
| ๋ด ์๊ทธ๋ฆผ์ draw.html(๊ธฐ์กด ์ฑ ๋ณด์กด). mark ํค๋๋ NVIDIA Maya-ACE(MIT) ํ์ โ | |
| assets3d/LICENSE-mark.txt ๊ณ ์ง ํฌํจ์ผ๋ก ์ฌ๋ฐฐํฌ ์กฐ๊ฑด ์ถฉ์กฑ. --> | |
| <style> | |
| :root { color-scheme: dark; } | |
| * { box-sizing: border-box; } | |
| body { | |
| margin: 0; min-height: 100vh; display: flex; flex-direction: column; align-items: center; | |
| font-family: 'Pretendard', 'Noto Sans KR', sans-serif; background: #16161d; color: #e8e8ef; | |
| } | |
| main { width: min(900px, 96vw); padding: 24px 0 48px; } | |
| h1 { font-size: 1.25rem; font-weight: 700; margin: 12px 0; } | |
| .duo { display: flex; gap: 14px; flex-wrap: wrap; justify-content: center; align-items: flex-start; } | |
| .stage { flex: 1 1 380px; max-width: 560px; aspect-ratio: 1; border-radius: 16px; overflow: hidden; border: 1px solid #2a2a35; position: relative; } | |
| /* ๋ถ์ ํจ๋: ์บ๋ฆญํฐ ์์์ ๋ด ์ผ๊ตด + MediaPipe ๋๋๋งํฌ + ํ์ ์ฑ๋๊ฐ์ ๋น๊ต๊ตฐ์ผ๋ก ๋ณด์ฌ์ค๋ค | |
| (๋ด์ฉ๋ฌผ์ ์ฝ์ด AvatarCore.makeMirrorPanel ์ด ์ธ๋ผ์ธ ์คํ์ผ๋ก ์์ฑ โ ์ฌ๊ธด ๋ฐฐ์น๋ง) */ | |
| #panel { flex: 0 0 280px; display: none; } | |
| #panel.on { display: block; } | |
| #recBtn.on { background: #e2647e; color: #fff; border-color: transparent; } | |
| @media (max-width: 620px) { | |
| main { padding: 12px 0 32px; } | |
| #panel { flex: 1 1 100%; max-width: 560px; } | |
| } | |
| #stage3d { background: #0d0d12; cursor: grab; } | |
| #stage2d { background: #fff; } | |
| .warp-shake { position: absolute; inset: 0; will-change: transform; } | |
| .warp-shake canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block; } | |
| #gl { z-index: 0; } #cv { z-index: 1; } | |
| #cv3d { width: 100%; height: 100%; display: block; } | |
| .row { display: flex; gap: 8px; margin-top: 14px; align-items: center; flex-wrap: wrap; } | |
| button { padding: 10px 16px; border-radius: 6px; border: 1px solid transparent; background: #e8e8ef; color: #16161d; font-size: .95rem; font-weight: 600; cursor: pointer; } | |
| button.alt { background: transparent; color: #c9c9d4; border-color: #3a3f47; } | |
| #mirrorBtn.on { background: #e2647e; color: #fff; } | |
| select { padding: 10px; border-radius: 6px; background: #0d0d12; color: inherit; border: 1px solid #3a3f47; font-size: .95rem; } | |
| a.link { color: #9a9ab0; font-size: .88rem; text-decoration: none; margin-left: auto; } | |
| #status { margin-top: 12px; color: #9a9ab0; font-size: .9rem; min-height: 1.2em; } | |
| #status.error { color: #ff7a7a; } | |
| footer { margin-top: 22px; font-size: .78rem; color: #66667a; line-height: 1.6; } | |
| footer a { color: inherit; text-decoration: underline; text-underline-offset: 3px; } | |
| /* ---- OBS ๋ธ๋ผ์ฐ์ ์์ค์ฉ ํด๋ฆฐ ๋ชจ๋ (?clean=1) ---- | |
| UI ๋ฅผ ์ ๋ถ ์จ๊ธฐ๊ณ ๋ฐฐ๊ฒฝ์ ํฌ๋ช ํ๊ฒ ๋ง๋ค์ด, ์บ๋ฆญํฐ๋ง ๋ฐฉ์ก ํ๋ฉด ์์ ์นํ๊ฒ ํ๋ค. */ | |
| body.clean { background: transparent; } | |
| body.clean h1, body.clean .row, body.clean #status, body.clean footer, body.clean #panel { display: none ; } | |
| body.clean main { width: 100vw; padding: 0; } | |
| body.clean .duo { gap: 0; } | |
| body.clean .stage { flex: 1 1 100vmin; max-width: 100vmin; border: 0; border-radius: 0; } | |
| /* #stage3d/#stage2d ๋ ID ๋ก ๋ฐฐ๊ฒฝ์ด ์ง์ ๋ผ ์์ด ๊ฐ์ ํน์ด๋(ID+ํด๋์ค)๋ก ๋ฎ์ด์ผ ํ๋ค */ | |
| body.clean #stage3d, body.clean #stage2d { background: transparent; } | |
| </style> | |
| <script type="importmap">{ "imports": { "three": "./vendor/three.module.js" } }</script> | |
| </head> | |
| <body> | |
| <main> | |
| <h1>DrawFace Live โ Webcam Face Mirroring</h1> | |
| <div class="duo"> | |
| <div class="stage" id="stage3d"><canvas id="cv3d"></canvas></div> | |
| <div class="stage" id="stage2d" style="display:none"><div class="warp-shake" id="shake"> | |
| <canvas id="gl" width="512" height="512"></canvas> | |
| <canvas id="cv" width="512" height="512"></canvas> | |
| </div></div> | |
| <div id="panel"></div> | |
| </div> | |
| <div class="row"> | |
| <button id="mirrorBtn">Start mirroring</button> | |
| <button class="alt" id="panelBtn">Analysis</button> | |
| <button class="alt" id="recBtn" title="Save just the character canvas as a WebM video">Record</button> | |
| <select id="charSel"> | |
| <option value="mark">Plaster bust, male (NVIDIA mark ยท 3D)</option> | |
| <option value="claire">Plaster bust, female (NVIDIA claire ยท 3D)</option> | |
| <option value="rpmA">Avatar A (Ready Player Me ยท 3D)</option> | |
| <option value="rpmB">Avatar B (Ready Player Me ยท 3D)</option> | |
| <option value="boy">Boy (graphic ยท 2D)</option> | |
| <option value="girl">Girl (graphic ยท 2D)</option> | |
| </select> | |
| <label style="display:flex;align-items:center;gap:6px;font-size:.9rem;color:#9a9ab0;cursor:pointer"> | |
| <input type="checkbox" id="headMove"> Head movement | |
| </label> | |
| <!-- ๋ ๋งํฌ๋ฅผ ํ ๋ฉ์ด๋ฆฌ๋ก ๋ฌถ๋๋ค โ ์๋ฌธ ๋ผ๋ฒจ์ด ๊ธธ์ด ์ค๋ฐ๊ฟ์ด ๋๋๋ฐ, ๋ฑ๊ฐ๋ฉด ํ๋๋ง | |
| ์๋๋ก ๋จ์ด์ ธ ๋๊ฐ ์๋์น ์์ ๋ณด์ธ๋ค. ๋ฌถ์ผ๋ฉด ๊ฐ์ด ๋ด๋ ค๊ฐ๋ค. --> | |
| <span style="margin-left:auto;display:flex;gap:12px;white-space:nowrap"> | |
| <a class="link" href="combo.html" style="margin-left:0">Five at once</a> | |
| <a class="link" href="draw.html" style="margin-left:0">My own drawing โ</a> | |
| </span> | |
| </div> | |
| <div id="status">Loading modelโฆ</div> | |
| <footer> | |
| Everything runs inside your browser โ no video is ever sent anywhere. | |
| Portraits and photoreal illustrations run on the local GPU track (LivePortrait, 30 fps) โ | |
| <a href="illust.html">see the results</a> ยท | |
| <a href="https://github.com/ingon1026/drawface-live#์ผ๋ฌ์คํธ-ํธ๋--liveportrait-์ค์๊ฐ-2026-07-23-์ฌ์ค์ธก">measurements</a>. | |
| mark and claire heads ยฉ NVIDIA, derived from <a href="assets3d/LICENSE-mark.txt">MIT (Maya-ACE)</a>. | |
| For a talking avatar, see <a href="https://ingon1026.github.io/talking-drawing-avatar/">talking-drawing-avatar</a>. | |
| </footer> | |
| </main> | |
| <script src="avatar_core.js"></script> | |
| <script type="module"> | |
| let THREE, GLTFLoader, MeshoptDecoder; | |
| try { | |
| THREE = await import("three"); | |
| // GLTFLoaderยทmeshopt ๋์ฝ๋๋ CDN(jsDelivr, three r160 ๋์ผ ๋ฒ์ ) ์ฐ์ โ Pages ์ ๊ท ๊ฒฝ๋ก์ | |
| // ์ฃ์ง 404 ์บ์๋ฅผ ํํผํ๋ค(MediaPipe ๋ ๊ฐ์ CDN ์ผ๋ก ๋ก๋ ์ค์ด๋ผ ๋๋ฌ์ฑ ๊ฒ์ฆ๋จ). ์คํจ ์ ๋ก์ปฌ ํด๋ฐฑ. | |
| try { | |
| ({ GLTFLoader } = await import("https://cdn.jsdelivr.net/npm/three@0.160.0/examples/jsm/loaders/GLTFLoader.js")); | |
| } catch { | |
| ({ GLTFLoader } = await import("./vendor/GLTFLoader.js?v=2")); | |
| } | |
| // mark.glb ๋ EXT_meshopt_compression (41MB โ 1.8MB) โ ๋์ฝ๋ ํ์ | |
| try { | |
| ({ MeshoptDecoder } = await import("https://cdn.jsdelivr.net/npm/three@0.160.0/examples/jsm/libs/meshopt_decoder.module.js")); | |
| } catch { | |
| ({ MeshoptDecoder } = await import("./vendor/meshopt_decoder.module.js")); | |
| } | |
| } catch (e) { | |
| // ์ง์ง ์์ธ ์ถ์ ์ฉ: ์คํจํ ๋ฆฌ์์ค์ ์ค์ HTTP ์ํ๋ฅผ ์ํ์ค์ ๋ ธ์ถ | |
| let detail = e.message; | |
| try { | |
| const r = await fetch("./vendor/GLTFLoader.js?v=2", { cache: "reload" }); | |
| detail += ` (local fetch: HTTP ${r.status})`; | |
| } catch (fe) { detail += ` (local fetch: ${fe.message})`; } | |
| document.getElementById("status").textContent = "Failed to load the 3D modules: " + detail; | |
| throw e; | |
| } | |
| const $ = id => document.getElementById(id); | |
| const setStatus = AvatarCore.bindStatus($("status")); | |
| AvatarCore.setLocale("en"); | |
| // ---- OBS ๋ธ๋ผ์ฐ์ ์์ค ๋ชจ๋ (?clean=1) ---- | |
| // UI ์จ๊น + ํฌ๋ช ๋ฐฐ๊ฒฝ์ผ๋ก ์บ๋ฆญํฐ๋ง ๋จ๊ธด๋ค. ?auto=1 ์ด๋ฉด ๋ก๋ ์งํ ๋ฏธ๋ฌ๋ง ์๋ ์์ | |
| // (OBS ๋ธ๋ผ์ฐ์ ์์ค๋ ํด๋ฆญ์ด ์์ผ๋ฏ๋ก โ ์นด๋ฉ๋ผ ๊ถํ์ OBS ๊ฐ ์๋ ํ์ฉ). | |
| const Q = new URLSearchParams(location.search); | |
| const CLEAN = Q.get("clean") === "1"; | |
| if (CLEAN) document.body.classList.add("clean"); | |
| if (Q.get("head") === "1") $("headMove").checked = true; // ์ฟผ๋ฆฌ๋ ์ฒดํฌ๋ฐ์ค ์ํ๋ก ๋ฐ์(๋จ์ผ ์ง์ค) | |
| // ================= ๊ณต์ : ๋ฏธ๋ฌ๋ง + ๊น๋นก์ ================= | |
| let smooth = {}; | |
| const W = k => smooth[k] || 0; | |
| const blinker = AvatarCore.makeBlink({ autoBlink: () => true, intervalMs: () => 3800, duration: 145, jitter: 0.7 }); | |
| const mirror = AvatarCore.makeMirror({ onStatus: setStatus }); | |
| $("mirrorBtn").onclick = () => { | |
| if (mirror.on) { | |
| mirror.stop(); $("mirrorBtn").classList.remove("on"); $("mirrorBtn").textContent = "Start mirroring"; | |
| scheduleDemo(3000); // ๋ฏธ๋ฌ๋ง ๋๋๋ฉด ์ ํด ์์ฐ ์ฌ๊ฐ | |
| return; | |
| } | |
| showcase.stop(); clearTimeout(demoTimer); // ์ง์ ์กฐ์ข ์์ โ ์์ฐ ์ฆ์ ์ค๋จ | |
| mirror.start().then(() => { $("mirrorBtn").classList.add("on"); $("mirrorBtn").textContent = "Stop mirroring"; panel.classList.add("on"); }) | |
| .catch(e => { mirror.stop(); $("mirrorBtn").classList.remove("on"); setStatus("Mirroring failed: " + e.message, true); }); | |
| }; | |
| // ================= ์๋ ์ผ์ผ์ด์ค: ์นด๋ฉ๋ผ ํ์ฉ ์ ์๋ ์ด์์๋ ๋ฐ๋ชจ ================= | |
| // ํฌ์ฆ(์ฑ๋๊ฐ) ์ํ์ค๋ฅผ EMA ๋ก ๋ถ๋๋ฝ๊ฒ ์๋ ด์์ผ ์์ฐ โ ์ํ์๋ ์ฝ์ด makeShowcase ์ฌ์ฌ์ฉ. | |
| // 3D/2D ๋ ๋๋ฌ๊ฐ ๊ฐ์ ์ฑ๋์ ์๋นํ๋ฏ๋ก ์๊ณ ์ยท์๋ ๋ชจ๋ ์๋ ์ ์ฉ. ๋ฏธ๋ฌ๋ง ์ค์ ์์ ํ ๊บผ์ง. | |
| const POSES = [ | |
| { mouthsmileleft: .9, mouthsmileright: .9, eyesquintleft: .4, eyesquintright: .4 }, // ํ์ง ๋ฏธ์ | |
| { jawopen: .75 }, // ์ | |
| { mouthpucker: .95, mouthfunnel: .4, jawopen: .12 }, // ์ฐ (์ค๋ฏ๋ฆผ) | |
| { jawopen: .45, browinnerup: 1, eyewideleft: .7, eyewideright: .7 }, // ๋๋ | |
| { eyeblinkleft: 1, mouthsmileleft: .6, mouthsmileright: .3 }, // ์ํฌ | |
| { eyelookoutleft: .9, eyelookinright: .9 }, // ์์ ์ผ์ชฝ | |
| { eyelookoutright: .9, eyelookinleft: .9 }, // ์์ ์ค๋ฅธ์ชฝ | |
| ]; | |
| let demoTarget = null, demoTimer = null; | |
| const demoW = {}; | |
| const showcase = AvatarCore.makeShowcase(pose => new Promise(res => { | |
| demoTarget = pose; | |
| setTimeout(() => { demoTarget = null; setTimeout(res, 320); }, 1300); | |
| }), POSES); | |
| function scheduleDemo(delay) { | |
| clearTimeout(demoTimer); | |
| demoTimer = setTimeout(() => { | |
| if (mirror.on || showcase.running) return; | |
| setStatus("Auto demo running โ hit Start mirroring to drive it yourself"); | |
| showcase.play().then(() => { | |
| if (!mirror.on) { setStatus("Hit Start mirroring and allow camera access"); scheduleDemo(5000); } | |
| }); | |
| }, delay); | |
| } | |
| function demoApply() { | |
| if (mirror.on) { for (const k in demoW) delete demoW[k]; return; } | |
| for (const k in demoW) if (!demoTarget || !(k in demoTarget)) demoW[k] *= 0.85; // ์ง๋ ํฌ์ฆ ์ฑ๋ ๊ฐ์ | |
| if (demoTarget) for (const k in demoTarget) demoW[k] = 0.85 * (demoW[k] || 0) + 0.15 * demoTarget[k]; | |
| for (const k in demoW) { | |
| if (demoW[k] < 0.01) delete demoW[k]; | |
| else smooth[k] = Math.max(smooth[k] || 0, demoW[k]); | |
| } | |
| } | |
| scheduleDemo(2200); // ์ฒซ ์ง์ โ ๋ชจ๋ธ ๋ก๋๋ ์ฆ์ ์์ฐ ์์ | |
| // ================= ๋ถ์ ํจ๋ (์ฝ์ด makeMirrorPanel โ face ํ์ด์ง๋ค๊ณผ ๊ณต์ฉ) ================= | |
| const panel = $("panel"); | |
| const mpanel = AvatarCore.makeMirrorPanel(mirror, panel); | |
| $("panelBtn").onclick = () => panel.classList.toggle("on"); | |
| // ================= ๋ นํ: ์บ๋ฆญํฐ ์บ๋ฒ์ค๋ง WebM ์ ์ฅ (draw.html ๊ณผ ๊ฐ์ ๋ฐฉ์) ================= | |
| // 3D ๋ cv3d ์ง์ , 2D ๋ gl+cv ๋ ๋ ์ด์ด๋ผ ์คํ์คํฌ๋ฆฐ ํฉ์ฑ ์บ๋ฒ์ค๋ฅผ ๋งค ํ๋ ์ ์ฑ์ ๋ นํ. | |
| let recorder = null, recCv = null; | |
| function stopRec() { | |
| if (recorder && recorder.state !== "inactive") recorder.stop(); | |
| } | |
| $("recBtn").onclick = () => { | |
| if (recorder) { stopRec(); return; } | |
| const mime = ["video/webm;codecs=vp9", "video/webm"].find(t => window.MediaRecorder && MediaRecorder.isTypeSupported(t)); | |
| if (!mime) { setStatus("This browser can't record WebM", true); return; } | |
| let stream; | |
| if (is3D()) stream = canvas3d.captureStream(30); // markยทclaire ๋ ๋ค canvas3d ์ ๋ ๋ | |
| else { | |
| recCv = document.createElement("canvas"); | |
| recCv.width = 512; recCv.height = 512; | |
| stream = recCv.captureStream(30); | |
| } | |
| const chunks = []; | |
| recorder = new MediaRecorder(stream, { mimeType: mime }); | |
| recorder.ondataavailable = e => e.data.size && chunks.push(e.data); | |
| recorder.onstop = () => { | |
| const a = document.createElement("a"); | |
| a.href = URL.createObjectURL(new Blob(chunks, { type: "video/webm" })); | |
| a.download = "drawface-live.webm"; | |
| a.click(); | |
| URL.revokeObjectURL(a.href); | |
| recorder = null; recCv = null; | |
| $("recBtn").classList.remove("on"); $("recBtn").textContent = "Record"; | |
| setStatus("Recording finished โ the WebM download has started"); | |
| }; | |
| recorder.start(); | |
| $("recBtn").classList.add("on"); $("recBtn").textContent = "Stop recording"; | |
| setStatus("Recording โ only the character canvas is saved (the webcam feed is never included)"); | |
| }; | |
| // ================= 3D: ์๊ณ ์ (mark) ================= | |
| const canvas3d = $("cv3d"); | |
| const renderer = new THREE.WebGLRenderer({ canvas: canvas3d, antialias: true, alpha: CLEAN }); | |
| renderer.setPixelRatio(Math.min(devicePixelRatio, 2)); | |
| if (CLEAN) renderer.setClearAlpha(0); | |
| const scene = new THREE.Scene(); | |
| scene.background = CLEAN ? null : new THREE.Color(0x0d0d12); // ํด๋ฆฐ ๋ชจ๋๋ ํฌ๋ช (OBS ํฉ์ฑ์ฉ) | |
| const camera = new THREE.PerspectiveCamera(28, 1, 0.1, 1000); | |
| scene.add(new THREE.HemisphereLight(0xdfe6ff, 0x2a2018, 1.1)); | |
| const key = new THREE.DirectionalLight(0xffffff, 2.2); key.position.set(1.5, 2, 3); scene.add(key); | |
| const rim = new THREE.DirectionalLight(0x77aaff, 1.0); rim.position.set(-2, 1, -2); scene.add(rim); | |
| const group = new THREE.Group(); | |
| scene.add(group); | |
| let mounted = null; // mountHead3D ๊ฒฐ๊ณผ { sceneNode, morphMeshes, eyes } | |
| const gltfLoader = new GLTFLoader(); | |
| gltfLoader.setMeshoptDecoder(MeshoptDecoder); | |
| // 3D ํค๋ ์ ์ โ ์ ๋ถ ARKit 52 ๋ชจํํ๊น์ด๋ผ ๊ตฌ๋ ์ฝ๋๋ ๊ณต์ฉ, ๊ฐ๋ณ ์ฐจ์ด๋ง ์ฌ๊ธฐ ์ ์ธ. | |
| // ์๊ณ (markยทclaire): ์ฐ๋ฆฌ ๋ฆฌํฌ ์์ฐ, ๋จธ๋ฆฌ๋ง ์๊ณ ์๊ตฌ ๋ฉ์๊ฐ ์์ด ๋์ ๊ตฌ์ฒด๋ฅผ ๋ฃ๋๋ค. | |
| // RPM: Ready Player Me CDN ์์ ๋ฐํ์ ๋ก๋(๋ฆฌํฌ์ ์ฌ๋ฐฐํฌํ์ง ์์ โ ์ ์ ๊ณต์ CDN ์ด ์ ์ ์ฌ์ฉ๋ฒ). | |
| // ๋ฐ์ ์ด๋ผ ์๋จ ์ผ๋ถ๋ง ๋จธ๋ฆฌ๋ก ์ก์ ํ๋ ์ด๋ฐํ๊ณ , ๋ยท๋จธ๋ฆฌ์นด๋ฝยท์ท์ด ์ด๋ฏธ ์์ด ๊ตฌ์ฒด ๋ถํ์ | |
| // (์์ ๋ eyeLook ๋ชจํ๋ก ์ง์ ๋์๊ฐ๋ค). | |
| // RPM ์๋ฐํ๋ jsDelivr(GitHub) ์ ๊ณต๊ฐ๋ ๊ฒ์ ๋ฐํ์ ๋ก๋ํ๋ค โ ์ฐ๋ฆฌ ๋ฆฌํฌ์ ์ฌ๋ฐฐํฌํ์ง ์๊ณ , | |
| // ์ปค๋ฐ SHA ๋ก ๊ณ ์ ํ๋ค(@main ์ ๋จ์ ๋ธ๋์น๋ผ force-pushยท์ญ์ ๋ก ์กฐ์ฉํ ๊นจ์ง ์ ์์). | |
| // ์ด๋ฏธ three.jsยท๋์ฝ๋๋ฅผ ๋ฐ์์ค๋ CDN ์ด๋ผ ๋๋ฌ์ฑยทCORS ๊ฐ ๊ฒ์ฆ๋ผ ์๋ค(models.readyplayer.me ๋ ์คํจ). | |
| const GH = "https://cdn.jsdelivr.net/gh/"; | |
| const HEADS = { | |
| mark: { url: "assets3d/mark.glb?v=3", label: "Plaster bust, male", eyeSpheres: true, headFrac: 1 }, | |
| claire: { url: "assets3d/claire.glb?v=3", label: "Plaster bust, female", eyeSpheres: true, headFrac: 1 }, | |
| rpmA: { url: GH + "AgoraIO-Community/agora-mediapipe-readyplayerme@c1aeedbd0501c1e1ad013947bf258639baf6085f/public/6644d711e53a2fb55928d72e.glb", | |
| label: "Avatar A", eyeSpheres: false, headFrac: 0.22 }, | |
| rpmB: { url: GH + "met4citizen/TalkingHead@eed58d198076a7e1e825f804802921c4d3804d46/avatars/brunette.glb", | |
| label: "Avatar B", eyeSpheres: false, headFrac: 0.22 }, | |
| }; | |
| const glbCache = {}; | |
| let loaded3D = null; // ํ์ฌ ํ๋ฉด์ ์ฌ๋ผ๊ฐ 3D ํค๋(๋ก๋ ์คํจ ์ ๋๋๋ฆฌ๊ธฐ์ฉ) | |
| function load3D(name) { | |
| const cfg = HEADS[name]; | |
| const done = g => { | |
| const r = AvatarCore.mountHead3D(THREE, { group, camera, gltf: g, cfg, prev: mounted }); | |
| if (!r) { setStatus(cfg.label + ": this model has no expression morph targets", true); return; } | |
| mounted = r; | |
| loaded3D = name; // ์บ์ ํํธ ํฌํจ, ํ๋ฉด์ ์ค์ ๋ก ์ฌ๋ผ๊ฐ ์์ ์ ํ์ | |
| setStatus("Hit Start mirroring and allow camera access"); | |
| }; | |
| if (glbCache[name]) return done(glbCache[name]); | |
| setStatus(cfg.label + " โ loadingโฆ"); | |
| gltfLoader.load(cfg.url, g => { glbCache[name] = g; done(g); }, | |
| undefined, () => { | |
| // ์คํจ ์ ํ๋ฉด์ ์ง์ ํค๋๊ฐ ๋จ์ผ๋ฏ๋ก ๋๋กญ๋ค์ด๋ ๋๋๋ ค UI ์ ํ๋ฉด์ ์ผ์น์ํจ๋ค | |
| setStatus(cfg.label + " failed to load โ check your network connection", true); | |
| $("charSel").value = loaded3D || "mark"; | |
| }); | |
| } | |
| // ์ด๊ธฐ ๋ก๋๋ ์บ๋ฆญํฐ ์ ํ๋ถ์์ ํ์ฌ selection ์ผ๋ก ํ ๋ฒ๋ง (mark/claire ์ด์ค๋ก๋ ๊ฒฝ์ ๋ฐฉ์ง) | |
| function resize3d() { | |
| const r = canvas3d.parentElement.getBoundingClientRect(); | |
| renderer.setSize(r.width, r.height, false); | |
| camera.aspect = 1; camera.updateProjectionMatrix(); | |
| } | |
| new ResizeObserver(resize3d).observe(canvas3d.parentElement); | |
| let dragging = false, px = 0, py = 0, userYaw = 0, userPitch = 0, nod = 0; | |
| canvas3d.addEventListener("pointerdown", e => { dragging = true; px = e.clientX; py = e.clientY; }); | |
| addEventListener("pointerup", () => dragging = false); | |
| addEventListener("pointermove", e => { | |
| if (!dragging) return; | |
| userYaw += (e.clientX - px) * 0.005; userPitch += (e.clientY - py) * 0.003; | |
| userPitch = Math.max(-0.4, Math.min(0.4, userPitch)); | |
| px = e.clientX; py = e.clientY; | |
| }); | |
| const gaze3d = AvatarCore.makeGaze(AvatarCore.makeCursorTracker(canvas3d), { mulX: 0.8, mulY: 0.5 }); | |
| // ================= 2D: ์๋ ยท์๋ (๋ฒกํฐ ์ + WebGL ์ํ + ๋๊ณต) ================= | |
| // ํ์ธ ์ถ์ฒ: talking-drawing-avatar ๊ธฐ๋ณธ ์บ๋ฆญํฐ ๊ท๊ฒฉ (baseยท๋์นยท๋ ๋ธ/๊ฐ์ยท๋๊ณต, ์ ์ ๋ฒกํฐ) | |
| const cv = $("cv"), ctx2d = cv.getContext("2d"); | |
| const glCanvas = $("gl"), shakeEl = $("shake"); | |
| const VECTOR_PARTS = ["base", "brow_L", "brow_R", "eye_L_open", "eye_R_open", | |
| "eye_L_closed", "eye_R_closed", "pupil_L", "pupil_R"]; | |
| let parts = {}, manifest = null; | |
| const charCache = {}; | |
| const gaze2d = AvatarCore.makeGaze(AvatarCore.makeCursorTracker($("stage2d")), { mulX: 0.9, mulY: 0.6 }); | |
| const headWander = AvatarCore.makeHeadWander(); | |
| const warp = AvatarCore.makeWarp({ | |
| threeUrl: "./vendor/three.module.js", glCanvas, | |
| getParts: () => parts, getManifest: () => manifest, W, | |
| }); | |
| async function loadChar(name) { | |
| if (!charCache[name]) { | |
| const m = await fetch(`characters/${name}/manifest.json`).then(r => r.json()); | |
| const ps = {}; | |
| await Promise.all(VECTOR_PARTS.map(f => new Promise(res => { | |
| const img = new Image(); | |
| img.onload = () => { ps[f] = img; res(); }; | |
| img.onerror = () => res(); | |
| img.src = `characters/${name}/${f}.png`; | |
| }))); | |
| charCache[name] = { parts: ps, manifest: m }; | |
| } | |
| ({ parts, manifest } = charCache[name]); | |
| warp.setCharacter(); | |
| } | |
| // ================= ์บ๋ฆญํฐ ์ ํ ================= | |
| // select ๊ฐ์ด ๋จ์ผ ์ง์ค โ markยทclaire=3D ํค๋ / boyยทgirl=2D ๊ทธ๋ํฝ | |
| const mode = () => $("charSel").value; | |
| const is3D = () => mode() in HEADS; // HEADS ์ ์ ์ธ๋ ๊ฒ์ด 3D ํค๋ (์๊ณ 2์ข + RPM 2์ข ) | |
| $("charSel").onchange = () => { | |
| stopRec(); // ๋ นํ ์คํธ๋ฆผ์ ๋ชจ๋๋ณ ์บ๋ฒ์ค์ ๋ฌผ๋ ค ์์ด ์ ํ ์ ์ข ๋ฃ | |
| $("stage3d").style.display = is3D() ? "" : "none"; | |
| $("stage2d").style.display = is3D() ? "none" : ""; | |
| if (is3D()) load3D(mode()); | |
| else loadChar(mode()).catch(e => setStatus("Character failed to load: " + e.message, true)); | |
| }; | |
| // ๋ฅ๋งํฌ: ?char=mark|claire|boy|girl ๋ก ์ง์ (draw.htmlยทboy.html ์ด ์ฌ์ฉ) | |
| const deep = Q.get("char"); | |
| if (deep && [...$("charSel").options].some(o => o.value === deep)) $("charSel").value = deep; | |
| $("charSel").onchange(); // ํ์ฌ selection ์ ํ ๋ฒ ๋ก๋ (๊ธฐ๋ณธ mark ๋๋ ๋ฅ๋งํฌ) | |
| // ?auto=1 โ OBS ์ฒ๋ผ ํด๋ฆญ์ด ๋ถ๊ฐ๋ฅํ ํ๊ฒฝ์์ ๋ฏธ๋ฌ๋ง ์๋ ์์ (๊ถํ ๊ฑฐ๋ถ ์ ์กฐ์ฉํ ๋ฌด์) | |
| if (Q.get("auto") === "1") { | |
| showcase.stop(); clearTimeout(demoTimer); | |
| mirror.start().then(() => { $("mirrorBtn").classList.add("on"); $("mirrorBtn").textContent = "Stop mirroring"; }) | |
| .catch(() => setStatus("Auto-mirroring failed โ check the camera permission", true)); | |
| } | |
| // ================= ๋ ๋ ๋ฃจํ (ํ์ฑ ์บ๋ฆญํฐ๋ง ๊ทธ๋ฆผ) ================= | |
| let lastFrame = 0; | |
| function render(now) { | |
| requestAnimationFrame(render); | |
| if (now - lastFrame < 30) return; // ~30fps ์บก โ ๋ฐ์ดํฐยท๋ นํ ๋ชจ๋ 30fps๋ผ ๊ณ ์ฃผ์ฌ์จ์์ ๋ญ๋น ๋ฐฉ์ง | |
| lastFrame = now; | |
| smooth = {}; | |
| demoApply(); // ์๋ ์ผ์ผ์ด์ค (๋ฏธ๋ฌ๋ง off ์ผ ๋๋ง) | |
| mirror.apply(smooth, now); | |
| const blink = Math.max(blinker.value(now), W("eyeblinkleft"), W("eyeblinkright")); | |
| mpanel.draw(W); // ํธ๋ํน ๋ฏธ๋ฆฌ๋ณด๊ธฐ | |
| if (is3D()) { | |
| if (!mounted) return; | |
| AvatarCore.applyMorphs(mounted.morphMeshes, smooth, blink); | |
| const [gsx, gsy] = gaze3d(W); | |
| for (const e of mounted.eyes) e.rotation.set(gsy * 0.3, gsx * 0.4, 0); | |
| nod = 0.85 * nod + 0.15 * W("jawopen"); | |
| // ๋จธ๋ฆฌ ํ์ : ํ ๊ธ ON ์ด๋ฉด ์น์บ ๋จธ๋ฆฌ ์์ธ๋ฅผ ์ ์ด, OFF ๋ฉด ํ์ ๋ง (๊ธฐ์กด ๋์ ์ ์ง) | |
| const h = $("headMove").checked ? mirror.head() : null; | |
| if (h) group.rotation.set(userPitch + h[1] + nod * 0.04, userYaw + h[0], h[2]); | |
| else group.rotation.set(userPitch + nod * 0.04, userYaw, 0); | |
| renderer.render(scene, camera); | |
| return; | |
| } | |
| // 2D ์บ๋ฆญํฐ (์๋ ยท์๋ ) โ ๊ทธ๋ฆฌ๊ธฐ๋ ์ฝ์ด, ์ํ ๋ ์ด์ด ํ ๊ธ๋ง ํ์ด์ง | |
| if (!parts.base || !manifest) return; | |
| // ๋จธ๋ฆฌ ์์ง์ ํ ๊ธ ON ์ด๋ฉด ์น์บ ๊ณ ๊ฐ ๋ฐฉํฅ์ ์ ํด ํ๋ค๋ฆผ ์์ ํฉ์ฑ (2D: roll=ํ์ , yaw/pitch=์ด๋) | |
| headWander(shakeEl, now, W("jawopen"), 0.5, null, $("headMove").checked ? mirror.head() : null); | |
| if (warp.ready) { glCanvas.style.visibility = "visible"; warp.render(); } | |
| else { glCanvas.style.visibility = "hidden"; } | |
| AvatarCore.drawChar2D(ctx2d, { parts, manifest, W, blink, gaze: gaze2d(W), warp, clearBg: !CLEAN }); | |
| if (recCv) { // 2D ๋ นํ ํฉ์ฑ โ ๋ ๋ ์ด์ด(gl ์ํ + cv ์ค๋ฒ๋ ์ด) | |
| const c = recCv.getContext("2d"); | |
| c.fillStyle = "#fff"; c.fillRect(0, 0, 512, 512); | |
| if (warp.ready) c.drawImage(glCanvas, 0, 0); | |
| c.drawImage(cv, 0, 0); | |
| } | |
| } | |
| warp.init(); | |
| loadChar(is3D() ? "boy" : mode()).catch(e => setStatus("Character failed to load: " + e.message, true)); // 2D ํ๋ฆฌ๋ก๋(3D ์ง์ ์ boy ์๋ฐ, 2D ์ง์ ์ ํ์ฑ ์บ๋ฆญํฐ ์ ์ง) | |
| requestAnimationFrame(render); | |
| </script> | |
| </body> | |
| </html> | |