Spaces:
Running
Running
| <html lang="en"> | |
| <head> | |
| <meta charset="utf-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1"> | |
| <title>DrawFace Live โ Five at Once</title> | |
| <!-- ๋ด ์ผ๊ตด(MediaPipe) + ์๊ณ ๋จ/์ฌ(markยทclaire) + ์๋ /์๋ ๋ฅผ ํ makeMirror ๋ก ๋์ ๊ตฌ๋. | |
| README ํ์ด๋ก์ฉ 5์นธ ํฉ์ฑ ๋ นํ ํ์ด์ง (๋ ์ด์์: ๋ | ์๊ณ ์์๋ | ์บ๋ฆญํฐ ์์๋). --> | |
| <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 { padding: 20px 0 40px; width: min(1000px, 97vw); } | |
| h1 { font-size: 1.1rem; font-weight: 700; } | |
| .grid { display: grid; grid-template-columns: 1.05fr 1fr 1fr; gap: 10px; align-items: center; } | |
| .cell { position: relative; aspect-ratio: 1; border-radius: 12px; overflow: hidden; border: 1px solid #2a2a35; } | |
| .cell.d3 { background: #0d0d12; } .cell.d2 { background: #fff; } | |
| .cell .tag { position: absolute; left: 8px; top: 6px; font-size: .72rem; color: #9a9ab0; z-index: 2; } | |
| #meCell { grid-row: span 2; background: #0d0d12; } | |
| canvas { width: 100%; height: 100%; display: block; } | |
| #meCell canvas { object-fit: contain; } | |
| .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-weight: 600; cursor: pointer; } | |
| button.alt { background: transparent; color: #c9c9d4; border-color: #3a3f47; } | |
| #mirrorBtn.on, #recBtn.on { background: #e2647e; color: #fff; border-color: transparent; } | |
| #status { margin-top: 10px; color: #9a9ab0; font-size: .9rem; min-height: 1.2em; } | |
| a.link { color: #9a9ab0; font-size: .85rem; margin-left: auto; text-decoration: none; } | |
| </style> | |
| <script type="importmap">{ "imports": { "three": "./vendor/three.module.js" } }</script> | |
| </head> | |
| <body> | |
| <main> | |
| <h1>Five at once โ my face โ plaster busts (M/F) + boy/girl</h1> | |
| <div class="grid"> | |
| <div class="cell" id="meCell"><span class="tag">Me (MediaPipe)</span><canvas id="meCv" width="360" height="360"></canvas></div> | |
| <div class="cell d3"><span class="tag">Bust M</span><canvas id="markCv"></canvas></div> | |
| <div class="cell d2"><span class="tag">Boy</span><canvas id="boyCv" width="400" height="400"></canvas></div> | |
| <div class="cell d3"><span class="tag">Bust F</span><canvas id="claireCv"></canvas></div> | |
| <div class="cell d2"><span class="tag">Girl</span><canvas id="girlCv" width="400" height="400"></canvas></div> | |
| </div> | |
| <div class="row"> | |
| <button id="mirrorBtn">Start mirroring</button> | |
| <button class="alt" id="recBtn">Record all five</button> | |
| <label style="display:flex;align-items:center;gap:6px;font-size:.9rem;color:#9a9ab0;cursor:pointer"> | |
| <input type="checkbox" id="headMove"> Head movement | |
| </label> | |
| <a class="link" href="./">โ Studio</a> | |
| </div> | |
| <div id="status">Loadingโฆ</div> | |
| </main> | |
| <script src="avatar_core.js"></script> | |
| <script type="module"> | |
| import * as THREE from "three"; | |
| import { GLTFLoader } from "https://cdn.jsdelivr.net/npm/three@0.160.0/examples/jsm/loaders/GLTFLoader.js"; | |
| import { MeshoptDecoder } from "https://cdn.jsdelivr.net/npm/three@0.160.0/examples/jsm/libs/meshopt_decoder.module.js"; | |
| const $ = id => document.getElementById(id); | |
| const setStatus = AvatarCore.bindStatus($("status")); | |
| AvatarCore.setLocale("en"); | |
| 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 }); | |
| // ๋ด ์ผ๊ตด ํจ๋: ์น์บ (๊ฑฐ์ธ) + MediaPipe 478์ (ํ์ฑ ์ฃผํฉ)์ meCv ์ ์ง์ ๊ทธ๋ฆฐ๋ค. | |
| // makeMirrorPanel ์ DOM ๋ง์ดํธ ๊ฐ์์ฑ(offsetParent)์ ์์กดํด ์ฌ๊ธฐ์ ๋ชป ์ฐ๊ณ , ๊ฐ์ ๊ทธ๋ฆผ์ ์ง์ . | |
| const meCv = $("meCv"), meCtx = meCv.getContext("2d"); | |
| function drawMe() { | |
| const w = meCv.width, h = meCv.height; | |
| const d = mirror.debug(); | |
| meCtx.fillStyle = "#0d0d12"; meCtx.fillRect(0, 0, w, h); | |
| if (!mirror.on || !d.video || d.video.readyState < 2) { | |
| meCtx.fillStyle = "#7a7a90"; meCtx.font = "15px sans-serif"; meCtx.textAlign = "center"; | |
| meCtx.fillText("Start mirroring to see", w / 2, h / 2 - 12); | |
| meCtx.fillText("your own face here", w / 2, h / 2 + 14); | |
| return; | |
| } | |
| meCtx.save(); meCtx.scale(-1, 1); meCtx.drawImage(d.video, -w, 0, w, h); meCtx.restore(); // ๊ฑฐ์ธ | |
| if (d.lm) { | |
| meCtx.fillStyle = "rgba(91,140,255,.85)"; | |
| for (let i = 0; i < 468; i++) meCtx.fillRect((1 - d.lm[i].x) * w - 1, d.lm[i].y * h - 1, 2, 2); | |
| meCtx.fillStyle = "#ffb03a"; // ํ์ฑ 10์ = ์์ ์ ๋ ฅ | |
| for (let i = 468; i < d.lm.length; i++) meCtx.fillRect((1 - d.lm[i].x) * w - 2, d.lm[i].y * h - 2, 4, 4); | |
| } | |
| } | |
| // ---------- 3D ํค๋ ํ๋ (mark/claire ๊ฐ์ ์ฌยท๋ ๋๋ฌ) ---------- | |
| const loader = new GLTFLoader(); loader.setMeshoptDecoder(MeshoptDecoder); | |
| function make3D(canvas, glbName) { | |
| const renderer = new THREE.WebGLRenderer({ canvas, antialias: true }); | |
| renderer.setPixelRatio(Math.min(devicePixelRatio, 2)); | |
| const scene = new THREE.Scene(); scene.background = new THREE.Color(0x0d0d12); | |
| 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, nod = 0; | |
| loader.load(`assets3d/${glbName}.glb?v=3`, g => { | |
| // ์๊ณ ํค๋ 2์ข ๋ง ์ฐ๋ฏ๋ก eyeSpheresยทheadFrac ๊ณ ์ (์ฝ์ด๊ฐ ๋ชจํ ์์งยท๋์ยทํ๋ ์ด๋ฐ๊น์ง) | |
| mounted = AvatarCore.mountHead3D(THREE, { group, camera, gltf: g, | |
| cfg: { eyeSpheres: true, headFrac: 1 } }); | |
| if (!mounted) { setStatus(glbName + ": no morph-target mesh", true); return; } | |
| ready(); | |
| }, undefined, e => setStatus(glbName + " failed to load: " + e, true)); | |
| const gaze = AvatarCore.makeGaze({ gx: 0, gy: 0 }, { mulX: 0.8, mulY: 0.5 }); | |
| function resize() { const r = canvas.getBoundingClientRect(); renderer.setSize(r.width, r.height, false); camera.aspect = 1; camera.updateProjectionMatrix(); } | |
| new ResizeObserver(resize).observe(canvas); | |
| return { render(blink, h) { | |
| if (!mounted) return; | |
| AvatarCore.applyMorphs(mounted.morphMeshes, smooth, blink); | |
| const [gx, gy] = gaze(W); | |
| for (const e of mounted.eyes) e.rotation.set(gy * 0.3, gx * 0.4, 0); | |
| nod = 0.85 * nod + 0.15 * W("jawopen"); | |
| // ๋จธ๋ฆฌ ํ์ : h ๊ฐ ์ค๋ฉด ์น์บ ๋จธ๋ฆฌ ์์ธ๋ฅผ ์น๊ณ , ์์ผ๋ฉด ๋๋์๋ง (๊ธฐ์กด ๋์) | |
| if (h) group.rotation.set(h[1] + nod * 0.04, h[0], h[2]); | |
| else group.rotation.set(nod * 0.04, 0, 0); | |
| renderer.render(scene, camera); | |
| } }; | |
| } | |
| // ---------- 2D ์บ๋ฆญํฐ ํ๋ (๋ฒกํฐ ์ , ์ํ ์์ โ ๊ฐ๋ฒผ์) ---------- | |
| const PARTS = ["base", "brow_L", "brow_R", "eye_L_open", "eye_R_open", "eye_L_closed", "eye_R_closed", "pupil_L", "pupil_R"]; | |
| function make2D(canvas, name) { | |
| const ctx = canvas.getContext("2d"); | |
| const gaze = AvatarCore.makeGaze({ gx: 0, gy: 0 }, { mulX: 0.9, mulY: 0.6 }); | |
| let parts = {}, manifest = null; | |
| (async () => { | |
| manifest = await fetch(`characters/${name}/manifest.json`).then(r => r.json()); | |
| await Promise.all(PARTS.map(f => new Promise(res => { const im = new Image(); im.onload = () => { parts[f] = im; res(); }; im.onerror = res; im.src = `characters/${name}/${f}.png`; }))); | |
| ready(); | |
| })(); | |
| // ์ํ ์์ด(๊ฒฝ๋) ์ฝ์ด๋ก ๊ทธ๋ฆฐ๋ค โ ๋์น ๋ด๋ฆผยทํฑ ์คํ์ ๊น์ง ์คํ๋์ค์ ๋์ผ ๊ท์น. | |
| // ๋จธ๋ฆฌ ์์ธ๋ CSS ๊ฐ ์๋๋ผ ์บ๋ฒ์ค ๋ณํ์ผ๋ก ๋๊ธด๋ค โ 5์นธ ๋ นํ๊ฐ drawImage ๋ก ํฉ์ฑํ๋๋ฐ | |
| // CSS ๋ณํ์ ๋นํธ๋งต์ ์ ๋ด๊ธฐ๊ธฐ ๋๋ฌธ(์คํ๋์ค๋ shake ๋ํผ๊ฐ ์์ด CSS ๊ฒฝ๋ก๋ฅผ ์ด๋ค). | |
| return { render(blink, h) { | |
| AvatarCore.drawChar2D(ctx, { parts, manifest, W, blink, gaze: gaze(W), head: h }); | |
| } }; | |
| } | |
| // canvas ํฌ๊ธฐ(3D)๋ CSS ํฌ๊ธฐ์ ๋ง์ถฐ์ผ ํจ โ ์ด๊ธฐ 400 | |
| ["markCv", "claireCv"].forEach(id => { $(id).width = 400; $(id).height = 400; }); | |
| const heads = { mark: make3D($("markCv"), "mark"), claire: make3D($("claireCv"), "claire") }; | |
| const chars = { boy: make2D($("boyCv"), "boy"), girl: make2D($("girlCv"), "girl") }; | |
| let readyN = 0; | |
| function ready() { if (++readyN >= 4) setStatus("Hit Start mirroring and allow camera access (or just watch the auto demo)"); } | |
| // ---------- ์๋ ์ผ์ผ์ด์ค (๋ฏธ๋ฌ๋ง ์ ์์ฐ) ---------- | |
| const POSES = [ | |
| { mouthsmileleft: .9, mouthsmileright: .9 }, { jawopen: .75 }, { mouthpucker: .95, jawopen: .12 }, | |
| { jawopen: .45, browinnerup: 1, eyewideleft: .7, eyewideright: .7 }, { eyeblinkleft: 1, mouthsmileright: .5 }, | |
| { 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(d) { clearTimeout(demoTimer); demoTimer = setTimeout(() => { if (mirror.on || showcase.running) return; setStatus("Auto demo running โ start mirroring to drive it yourself"); showcase.play().then(() => { if (!mirror.on) scheduleDemo(4000); }); }, d); } | |
| 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); | |
| $("mirrorBtn").onclick = () => { | |
| if (mirror.on) { mirror.stop(); $("mirrorBtn").classList.remove("on"); $("mirrorBtn").textContent = "Start mirroring"; scheduleDemo(2000); return; } | |
| showcase.stop(); clearTimeout(demoTimer); | |
| mirror.start().then(() => { $("mirrorBtn").classList.add("on"); $("mirrorBtn").textContent = "Stop mirroring"; }) | |
| .catch(e => { mirror.stop(); setStatus("Mirroring failed: " + e.message, true); }); | |
| }; | |
| // ---------- ๋ ๋ ๋ฃจํ ---------- | |
| let lastFrame = 0; | |
| function frame(now) { | |
| requestAnimationFrame(frame); | |
| if (now - lastFrame < 30) return; // ~30fps ์บก โ 5์นธ ๋ ๋๋ ๋ฌด๊ฑฐ์ด๋ฐ ๋ฐ์ดํฐยท๋ นํ ๋ค 30fps | |
| lastFrame = now; | |
| smooth = {}; | |
| demoApply(); | |
| mirror.apply(smooth, now); | |
| const blink = Math.max(blinker.value(now), W("eyeblinkleft"), W("eyeblinkright")); | |
| drawMe(); // ๋ด ์ผ๊ตด + ๋๋๋งํฌ | |
| const head = $("headMove").checked ? mirror.head() : null; // ๋ค ์บ๋ฆญํฐ ๊ณตํต ๋จธ๋ฆฌ ์์ธ | |
| heads.mark.render(blink, head); heads.claire.render(blink, head); | |
| chars.boy.render(blink, head); chars.girl.render(blink, head); | |
| if (recCtx) compositeRec(); | |
| } | |
| requestAnimationFrame(frame); | |
| // ---------- 5์นธ ํฉ์ฑ ๋ นํ (1200ร800): [๋ | ์๊ณ ๋จ/์ฌ | ์๋ /๋ ] ---------- | |
| let recorder = null, recCanvas = null, recCtx = null; | |
| function compositeRec() { | |
| const c = recCtx; | |
| c.fillStyle = "#16161d"; c.fillRect(0, 0, 1200, 800); | |
| c.drawImage($("meCv"), 20, 220, 360, 360); // ๋ (์ธ๋ก ๊ฐ์ด๋ฐ) | |
| c.drawImage($("markCv"), 400, 0, 400, 400); // ์๊ณ ๋จ | |
| c.drawImage($("claireCv"), 400, 400, 400, 400); // ์๊ณ ์ฌ | |
| c.fillStyle = "#fff"; c.fillRect(800, 0, 400, 800); | |
| c.drawImage($("boyCv"), 800, 0, 400, 400); // ์๋ | |
| c.drawImage($("girlCv"), 800, 400, 400, 400); // ์๋ | |
| } | |
| $("recBtn").onclick = () => { | |
| if (recorder) { recorder.stop(); 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; } | |
| recCanvas = document.createElement("canvas"); recCanvas.width = 1200; recCanvas.height = 800; | |
| recCtx = recCanvas.getContext("2d"); | |
| const chunks = []; | |
| recorder = new MediaRecorder(recCanvas.captureStream(30), { 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-5.webm"; a.click(); | |
| URL.revokeObjectURL(a.href); recorder = null; recCtx = null; recCanvas = null; | |
| $("recBtn").classList.remove("on"); $("recBtn").textContent = "Record all five"; setStatus("Recording finished โ WebM saved"); | |
| }; | |
| recorder.start(); $("recBtn").classList.add("on"); $("recBtn").textContent = "Stop recording"; | |
| setStatus("Recording โ saving the five-panel composite (the raw webcam feed is never included)"); | |
| }; | |
| </script> | |
| </body> | |
| </html> | |