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