drawface-live / index.html
ingon1's picture
sync from 3242e8b5eb65228f125c10f9446f72771e40fb4c
506b80a verified
Raw
History Blame Contribute Delete
22.6 kB
<!DOCTYPE html>
<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 !important; }
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>