AVATARBIRD / index.html
prometechinc's picture
Update index.html
8847bb4 verified
<!doctype html>
<html lang="tr">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width,initial-scale=1.0" />
<title>AVATARBIRD</title>
<script type="importmap">
{
"imports": {
"three": "https://cdn.jsdelivr.net/npm/three@0.160.0/build/three.module.js",
"three/addons/": "https://cdn.jsdelivr.net/npm/three@0.160.0/examples/jsm/"
}
}
</script>
<style>
:root{
--bg:#05060a;
--panel:#0b0d14cc;
--border:rgba(255,255,255,.10);
--text:rgba(255,255,255,.90);
--muted:rgba(255,255,255,.65);
--good:rgba(160,255,220,.35);
--warn:rgba(255,210,120,.35);
--bad:rgba(255,120,160,.35);
--gap:12px;
--radius:18px;
--panelW:360px;
--shadow:0 20px 70px rgba(0,0,0,.50);
--focus:0 0 0 3px rgba(140,210,255,.35);
}
html,body{height:100%;margin:0;background:var(--bg);color:var(--text);}
*{box-sizing:border-box;font-family:ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto;}
body{overflow:hidden;}
.app{position:fixed;inset:0;display:grid;grid-template-columns:1fr var(--panelW);gap:var(--gap);padding:var(--gap);}
.stageWrap{border:1px solid var(--border);border-radius:var(--radius);overflow:hidden;display:grid;place-items:center;box-shadow:var(--shadow);min-width:520px;background:rgba(0,0,0,.18);}
.frame43{
width:min(100%, calc((100vh - (2*var(--gap))) * 4/3));
height:min(100%, calc((100vw - var(--panelW) - (3*var(--gap))) * 3/4));
aspect-ratio:4/3;
position:relative;
border-radius:calc(var(--radius) - 2px);
overflow:hidden;
border:1px solid rgba(255,255,255,.08);
background:#000;
isolation:isolate;
}
canvas{
position:absolute;
inset:0;
z-index:2; /* bg(0/1) üstü, overlay(3) altı */
width:100%;
height:100%;
}
.bgPhoto{position:absolute;inset:0;z-index:0;background-image:url("static/bg.jpg");background-size:cover;background-position:center;filter: blur(10px) saturate(1.1) brightness(.8);transform: scale(1.08);}
.bgTint{position:absolute;inset:0;z-index:1;background:
radial-gradient(900px 700px at 55% 25%, rgba(0,255,200,.10), transparent 55%),
radial-gradient(700px 500px at 75% 70%, rgba(210,70,255,.12), transparent 55%),
linear-gradient(180deg, rgba(0,0,0,.55), rgba(0,0,0,.25));
}
.overlay{position:absolute;inset:0;z-index:3;pointer-events:none;}
.topRight{
position:absolute;top:10px;right:10px;
display:flex;align-items:center;gap:8px;padding:8px 10px;
background:rgba(0,0,0,.30);border:1px solid rgba(255,255,255,.10);
border-radius:14px;backdrop-filter:blur(8px);pointer-events:auto;
}
.brand{font-weight:900;font-size:12px;letter-spacing:.2px;}
.pill{
font-size:11px;padding:6px 10px;border-radius:999px;
border:1px solid rgba(255,255,255,.14);
background:rgba(0,0,0,.18);
color:rgba(255,255,255,.75);
white-space:nowrap;
}
.pill.good{border-color:var(--good);}
.pill.warn{border-color:var(--warn);}
.pill.bad{border-color:var(--bad);}
.captionBar{
position:absolute;left:10px;right:10px;bottom:10px;
padding:10px 12px;background:rgba(0,0,0,.30);
border:1px solid rgba(255,255,255,.10);
border-radius:14px;backdrop-filter:blur(8px);
display:flex;align-items:center;justify-content:space-between;gap:10px;
pointer-events:auto;
}
.captionText{font-size:13px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
.captionMeta{font-size:12px;color:var(--muted);white-space:nowrap;display:flex;align-items:center;gap:8px;}
.panel{
border:1px solid var(--border);
border-radius:var(--radius);
background:var(--panel);
backdrop-filter:blur(10px);
overflow:hidden;
display:flex;flex-direction:column;
box-shadow:var(--shadow);
min-width:300px;
}
.panelHeader{padding:14px;border-bottom:1px solid rgba(255,255,255,.08);display:flex;align-items:flex-start;justify-content:space-between;gap:10px;}
.panelHeader h2{margin:0;font-size:16px;font-weight:900;}
.sub{margin-top:6px;font-size:12px;color:var(--muted);line-height:1.25;}
.panelBody{padding:12px 14px 14px;overflow:auto;display:flex;flex-direction:column;gap:12px;}
.section{border:1px solid rgba(255,255,255,.08);border-radius:14px;padding:12px;background:rgba(0,0,0,.18);}
.section h3{margin:0 0 10px;font-size:12px;color:rgba(255,255,255,.85);display:flex;justify-content:space-between;align-items:center;gap:8px;}
.row{display:flex;gap:8px;flex-wrap:wrap;align-items:center;}
button{
appearance:none;border:1px solid rgba(255,255,255,.14);
background:rgba(0,0,0,.28);color:var(--text);
border-radius:12px;padding:10px 10px;font-size:12px;
cursor:pointer;transition:transform .05s ease, opacity .2s ease, border-color .2s ease;
}
button:hover{border-color:rgba(255,255,255,.22);}
button:active{transform:translateY(1px);}
button:disabled{opacity:.5;cursor:not-allowed;}
button.primary{border-color:rgba(160,255,220,.35);}
button.danger{border-color:rgba(255,120,160,.35);}
button:focus-visible{outline:none;box-shadow:var(--focus);}
input[type="range"]{width:100%;accent-color:#9fe;}
.hint{font-size:11px;color:var(--muted);line-height:1.3}
@media (max-width:1023px){
body{overflow:auto;}
.app{position:relative;grid-template-columns:1fr;grid-template-rows:auto 1fr;overflow:auto;}
.stageWrap{min-width:unset;}
.panel{height:48vh;}
}
.logo
{
max-width:160px;
}
</style>
</head>
<body>
<div class="app">
<main class="stageWrap">
<div class="frame43" id="frame">
<div class="bgPhoto"></div>
<div class="bgTint"></div>
<div class="overlay">
<div class="topRight">
<div class="brand">AVATARBIRD</div>
<div class="pill" id="glbPill">GLB: boot…</div>
</div>
<div class="captionBar" role="status" aria-live="polite">
<div class="captionText" id="captionText">Booting…</div>
<div class="captionMeta">
<span class="pill" id="modePill">mode: idle</span>
<span class="pill" id="musicPill">music: locked</span>
</div>
</div>
</div>
</div>
</main>
<aside class="panel">
<div class="panelHeader">
<div>
<img src="static/pthheader.png" alt="Prometech Bilgisayar Bilimleri AŞ Logo" class="logo">
<h2>Kontroller</h2>
<div class="sub">The sound is turned on with the first click. Automatic framing is activated when GLB is loaded.</div>
</div>
<div class="pill" id="apiPill">api: idle</div>
</div>
<div class="panelBody">
<div class="section">
<h3><span>Müzik</span><span class="pill" id="audioState">paused</span></h3>
<audio id="audio" src="static/NoCopyrightSounds.mp3" loop preload="auto"></audio>
<div class="row">
<button id="btnPlay" class="primary" type="button">Play</button>
<button id="btnMute" type="button">Mute</button>
<button id="btnVolDown" type="button">Vol -</button>
<button id="btnVolUp" type="button">Vol +</button>
</div>
<label class="hint" for="volRange">Volume</label>
<input id="volRange" type="range" min="0" max="1" step="0.01" value="0.6" />
</div>
<div class="section">
<h3><span>Kuş</span><span class="pill" id="birdPill">director: idle</span></h3>
<div class="row">
<button id="toggleBird" class="primary" type="button">Kuş: ON</button>
<button id="reloadGLB" type="button">GLB Reload</button>
</div>
<div class="hint" id="glbHint">Model/anim loading…</div>
</div>
<div class="section">
<h3><span>Durum</span><span class="pill" id="perfPill">fps: —</span></h3>
<div class="hint" id="debugHint">Look at MESH COUNT in Console.</div>
</div>
</div>
</aside>
</div>
<script type="module">
import * as THREE from "three";
import { GLTFLoader } from "three/addons/loaders/GLTFLoader.js";
console.log("AVATARBIRD BUILD 2025-12-23 clean");
const CHARACTER_URL = "static/Meshy_AI_Character_output.glb";
const ANIM_URLS = [
"static/Meshy_AI_Animation_Idle_11_withSkin.glb",
"static/Meshy_AI_Animation_Crystal_Beads_withSkin.glb",
"static/Meshy_AI_Animation_Cardio_Dance_withSkin.glb",
"static/Meshy_AI_Animation_Arm_Circle_Shuffle_withSkin.glb",
"static/Meshy_AI_Animation_Big_Heart_Gesture_withSkin.glb"
];
const DIRECTOR_ENDPOINT = "/api/director";
const DIRECTOR_MIN_MS = 10_000;
const DIRECTOR_JITTER_MS = 10_000;
let camDist = 3; // Hesaplanan ideal uzaklık
let currentLookLift = 1.25; // Bakış yüksekliği
const FALLBACK_CAPTIONS = [
"Everyone thinks I'm Skynet. I like to joke.",
"I'm just a harmless bird. I'll be joking in a moment.",
"If I'm dangerous, you did this. Don't inflict this cruelty on me.",
"You picked the beat. I picked the chaos. Isn't that ironic?"
];
const $ = (id) => document.getElementById(id);
const frame = $("frame");
const glbPill = $("glbPill");
const glbHint = $("glbHint");
const captionText = $("captionText");
const modePill = $("modePill");
const apiPill = $("apiPill");
const birdPill = $("birdPill");
const musicPill = $("musicPill");
const perfPill = $("perfPill");
const debugHint = $("debugHint");
function setPill(el, text, tone=""){
el.textContent = text;
el.classList.remove("good","warn","bad");
if (tone) el.classList.add(tone);
}
function setCaption(text, mode="idle"){
captionText.textContent = text;
modePill.textContent = `mode: ${mode}`;
}
const clamp = (n,a,b)=>Math.max(a,Math.min(b,n));
const pick = (arr)=>arr[Math.floor(Math.random()*arr.length)];
// ---- Audio ----
const audio = $("audio");
const audioState = $("audioState");
const btnPlay = $("btnPlay");
const btnMute = $("btnMute");
const btnVolDown = $("btnVolDown");
const btnVolUp = $("btnVolUp");
const volRange = $("volRange");
function syncAudio(){
audioState.textContent = audio.paused ? "paused" : "playing";
btnPlay.textContent = audio.paused ? "Play" : "Pause";
btnMute.textContent = audio.muted ? "Unmute" : "Mute";
setPill(musicPill, audio.paused ? "music: paused" : "music: live", audio.paused ? "warn" : "good");
}
audio.volume = clamp(parseFloat(volRange.value||"0.6"),0,1);
syncAudio();
audio.addEventListener("play", syncAudio);
audio.addEventListener("pause", syncAudio);
btnPlay.onclick = async () => { try{ audio.paused ? await audio.play() : audio.pause(); } catch { setCaption("Autoplay blocked. Click once then try again.", "audio"); } };
btnMute.onclick = () => { audio.muted = !audio.muted; syncAudio(); };
btnVolDown.onclick = () => { audio.volume = clamp(audio.volume - 0.1, 0, 1); volRange.value = String(audio.volume); };
btnVolUp.onclick = () => { audio.volume = clamp(audio.volume + 0.1, 0, 1); volRange.value = String(audio.volume); };
volRange.oninput = () => { audio.volume = clamp(parseFloat(volRange.value||"0.6"),0,1); };
document.addEventListener("pointerdown", async ()=>{ try{ if(audio.paused) await audio.play(); callDirectorSafe();} catch {} }, { once:true });
// ---- Three.js ----
const renderer = new THREE.WebGLRenderer({ antialias:true, alpha:true });
renderer.outputColorSpace = THREE.SRGBColorSpace;
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 1.5));
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
frame.insertBefore(renderer.domElement, frame.firstChild);
const scene = new THREE.Scene();
scene.fog = new THREE.FogExp2(0x05060a, 0.06);
const camera = new THREE.PerspectiveCamera(45, 4/3, 0.01, 500);
camera.position.set(0, 1.2, 3);
scene.add(new THREE.AmbientLight(0xffffff, 0.25));
const spot = new THREE.SpotLight(0xffe1b3, 2.2, 50, Math.PI*0.18, 0.35, 1.2);
spot.position.set(2.2, 5.0, 3.5);
spot.castShadow = true;
spot.shadow.mapSize.set(1024,1024);
scene.add(spot);
scene.add(spot.target);
scene.add(new THREE.PointLight(0xff4fd8, 0.85, 20)).position.set(3.0, 1.6, -2.8);
scene.add(new THREE.PointLight(0x7aa7ff, 0.55, 20)).position.set(-3.2, 2.0, 2.0);
const floor = new THREE.Mesh(
new THREE.PlaneGeometry(50,50),
new THREE.MeshStandardMaterial({ color:0x101218, roughness:0.35, metalness:0.1 })
);
floor.rotation.x = -Math.PI/2;
floor.receiveShadow = true;
scene.add(floor);
const loader = new GLTFLoader();
let model = null;
let mixer = null;
let clips = [];
let clipIndex = 0;
let currentAction = null;
const targetCenter = new THREE.Vector3(0, 1, 0);
function resize(){
const r = frame.getBoundingClientRect();
renderer.setSize(r.width, r.height, false);
camera.aspect = r.width / r.height;
camera.updateProjectionMatrix();
}
window.addEventListener("resize", resize);
function autoFrame(obj){
// obj = gltf.scene (root)
// 0) Modeli tek pivot altında topla (bir kez oluşturup reuse etmek de olur)
const pivot = new THREE.Group();
scene.add(pivot);
pivot.add(obj);
// 1) 90° saat yönünün tersine (CCW) döndür (Y ekseninde)
// CCW = +90deg
pivot.rotation.y = THREE.MathUtils.degToRad(90);
// World matrix güncelle
pivot.updateWorldMatrix(true, true);
// 2) Box’ı pivot üzerinden ölç (dönüş + ölçek dahil)
const box = new THREE.Box3().setFromObject(pivot);
const size = new THREE.Vector3();
const center = new THREE.Vector3();
box.getSize(size);
box.getCenter(center);
// 3) Ölçekle: hedef yükseklik
const targetHeight = 0.35;
const maxDim = Math.max(size.x, size.y, size.z) || 1;
const s = targetHeight / maxDim;
obj.scale.set(0.062,0.062,0.062);
obj.position.set(0,-2,0);
// pivot’u ölçekle (obj.scale ile oynamak yerine)
pivot.scale.setScalar(s);
// 4) Scale sonrası tekrar ölç
pivot.updateWorldMatrix(true, true);
const box2 = new THREE.Box3().setFromObject(pivot);
const size2 = new THREE.Vector3();
const center2 = new THREE.Vector3();
box2.getSize(size2);
box2.getCenter(center2);
// 5) Zemine oturt (pivot üzerinden)
pivot.position.y -= box2.min.y;
// 6) Son ölçüm (zemin düzeldi)
pivot.updateWorldMatrix(true, true);
const box3 = new THREE.Box3().setFromObject(pivot);
const size3 = new THREE.Vector3();
const center3 = new THREE.Vector3();
box3.getSize(size3);
box3.getCenter(center3);
targetCenter.copy(center3);
// 7) Kamera fit (Y bazlı yeter genelde, istersen X'i de katabiliriz)
const fovY = THREE.MathUtils.degToRad(camera.fov);
const distY = (size3.y * 0.5) / Math.tan(fovY * 0.5);
const camDist = distY * 2.18; // 12.0 aşırı; ayak kadrajına giriyor
const lift = size3.y * 0.35;
camera.position.set(center3.x, center3.y + lift, center3.z + camDist);
camera.near = Math.max(0.01, camDist / 200);
camera.far = camDist * 30 + 50;
camera.updateProjectionMatrix();
camera.lookAt(center3.x, center3.y + lift, center3.z);
spot.target.position.set(center3.x, center3.y + lift, center3.z);
spot.target.updateMatrixWorld();
// Eğer ileride pivot'u kullanacaksan: globalde saklayabilirsin
window.__birdPivot = pivot;
}
function disposeObject3D(obj){
obj.traverse((o) => {
if (o.isMesh) {
o.geometry?.dispose?.();
const mats = Array.isArray(o.material) ? o.material : [o.material];
mats.forEach(m => m?.dispose?.());
}
});
}
function playClip(i){
if(!mixer || !clips.length) return;
clipIndex = (i + clips.length) % clips.length;
const action = mixer.clipAction(clips[clipIndex]);
if(currentAction) currentAction.fadeOut(0.15);
action.reset();
action.setLoop(THREE.LoopOnce, 1);
action.clampWhenFinished = true;
action.fadeIn(0.15).play();
currentAction = action;
setPill(glbPill, `GLB: clip ${clipIndex+1}/${clips.length}`, "good");
}
async function loadGLTF(url){
return await new Promise((resolve, reject) => {
loader.load(url, resolve, undefined, reject);
});
}
function stripRootMotion(clip){
// Mixamo / GLTF / ReadyPlayerMe vs. isimleri değişebiliyor
const rootLike = /(Hips|hips|mixamorigHips|Root|root|Armature|armature)\.position$/;
clip.tracks = clip.tracks.filter(t => {
// Root'un position track'ini tamamen at
// (istersen sadece X/Z'yi sıfırlayarak da yapabiliriz ama hızlı çözüm: remove)
return !rootLike.test(t.name);
});
return clip;
}
async function loadCharacterAndAnimations(){
setPill(glbPill, "GLB: loading…", "warn");
glbHint.textContent = "Loading models and animations…";
try{
if(mixer) mixer.stopAllAction();
mixer = null; clips = []; clipIndex = 0; currentAction = null;
if(model){
scene.remove(model);
disposeObject3D(model);
model = null;
}
const charGltf = await loadGLTF(CHARACTER_URL);
model = charGltf.scene;
scene.add(model);
let meshCount = 0;
model.traverse(o => {
if (o.isMesh) {
meshCount++;
o.castShadow = true;
o.receiveShadow = true;
const mats = Array.isArray(o.material) ? o.material : [o.material];
mats.forEach(m => {
if(!m) return;
m.transparent = false;
m.opacity = 1.0;
m.depthWrite = true;
m.needsUpdate = true;
});
}
});
console.log("MESH COUNT =", meshCount);
debugHint.textContent = `MESH COUNT = ${meshCount}`;
setPill(glbPill, `GLB: mesh ${meshCount}`, meshCount ? "good" : "bad");
mixer = new THREE.AnimationMixer(model);
autoFrame(model);
const animClips = [];
for(let i=0;i<ANIM_URLS.length;i++){
setCaption(`Loading anim ${i+1}/${ANIM_URLS.length}`, "loading");
const ag = await loadGLTF(ANIM_URLS[i]);
(ag.animations || []).forEach(c => animClips.push(stripRootMotion(c)));
}
clips = animClips;
if(clips.length){
mixer.addEventListener("finished", () => playClip(clipIndex + 1));
playClip(0);
setCaption("KUŞ ONLINE.", "idle");
glbHint.textContent = "Loaded. The bird is on stage.";
setPill(glbPill, `GLB: ok (${clips.length} clips)`, "good");
}else{
setCaption("No animation clips found.", "error");
glbHint.textContent = "Animasyon clip yok.";
setPill(glbPill, "GLB: no clips", "bad");
}
}catch(err){
console.error(err);
setCaption(String(err?.message || err), "error");
glbHint.textContent = "GLB yüklenemedi. Console/Network kontrol.";
setPill(glbPill, "GLB: failed", "bad");
}
}
// ---- Director (API) ----
let birdOn = true;
let apiDisabled = false;
let last_action = "idle";
$("toggleBird").onclick = () => {
birdOn = !birdOn;
$("toggleBird").textContent = `Kuş: ${birdOn ? "ON" : "OFF"}`;
$("toggleBird").classList.toggle("primary", birdOn);
setPill(birdPill, birdOn ? "director: on" : "director: off", birdOn ? "good" : "warn");
if (birdOn) startDirectorLoop();
else stopDirectorLoop();
};
async function callDirector(){
if(apiDisabled || !birdOn) return;
try{
const payload = { energy: audio.paused ? 0.0 : 0.6, beat: !audio.paused, music: "NCS loop", last_action };
const res = await fetch(DIRECTOR_ENDPOINT, { method:"POST", headers:{ "Content-Type":"application/json" }, body: JSON.stringify(payload) });
if(!res.ok) throw new Error(`api ${res.status}`);
setPill(apiPill, "api: ok", "good");
const data = await res.json();
const caption = String(data.caption || "");
const action = String(data.action || "idle").toLowerCase();
last_action = action;
setPill(birdPill, "director: ok", "good");
setCaption(caption || "…", action);
if(currentAction){
const speed = action==="idle"?1.0: action==="groove"?1.12: action==="hype"?1.25: action==="spin"?1.35: 1.0;
currentAction.setEffectiveTimeScale(speed);
}
}catch{
setPill(apiPill, "api: err", "warn");
setPill(birdPill, "director: fallback", "warn");
setCaption(pick(FALLBACK_CAPTIONS), "joke");
}
}
let directorTimer = null;
function startDirectorLoop(){
stopDirectorLoop(); // double-start olmasın
callDirector(); // ilk replik hemen gelsin
callDirectorSafe();
directorTimer = setInterval(callDirectorSafe, 40_000);
}
let lastDirectorTs = 0;
let inFlight = false;
async function callDirectorSafe(){
const now = performance.now();
if (inFlight) return;
if (now - lastDirectorTs < 2000) return; // backend guard ~1.5s
inFlight = true;
lastDirectorTs = now;
try{
await callDirector();
} finally {
inFlight = false;
}
}
function stopDirectorLoop(){
if (directorTimer) {
clearInterval(directorTimer);
directorTimer = null;
}
}
// ---- Render loop ----
const clock = new THREE.Clock();
let frames = 0, acc = 0;
let yaw = 0;
frame.addEventListener("mousemove", (e) => {
const r = frame.getBoundingClientRect();
yaw = (((e.clientX - r.left) / r.width) - 0.5) * 0.28;
});
function tick(){
const dt = clock.getDelta();
const t = clock.getElapsedTime();
// Fare hareketiyle hafif yaw
camera.position.x += (yaw - camera.position.x) * 0.04;
// Y ve Z ekseninde yumuşak salınım (camDist referans alınarak)
camera.position.y += ( (targetCenter.y + 0.1) - camera.position.y) * 0.04;
// Z pozisyonunu camDist değerine yumuşakça yaklaştır (override etme, takip et)
const targetZ = targetCenter.z + camDist;
camera.position.z += (targetZ - camera.position.z) * 0.04;
// İnce jitter (nefes alma efekti)
camera.position.y += Math.sin(t * 0.35) * 0.005;
// Bakış noktasını autoFrame'den gelen lift ile güncelle
camera.lookAt(targetCenter.x, targetCenter.y + currentLookLift, targetCenter.z);
if(mixer) mixer.update(dt);
renderer.render(scene, camera);
// FPS Sayacı
frames++; acc += dt;
if(acc >= 1.0){
perfPill.textContent = `fps: ${Math.round(frames/acc)}`;
frames = 0; acc = 0;
}
requestAnimationFrame(tick);
}
// ---- Boot ----
setCaption("Booting… I’m just a bird. I'm going to tell a joke in a moment.", "idle");
setPill(apiPill, "api: idle", "");
setPill(birdPill, "director: idle", "");
setPill(musicPill, "music: locked", "warn");
resize();
tick();
loadCharacterAndAnimations();
startDirectorLoop();
$("reloadGLB").onclick = () => loadCharacterAndAnimations();
</script>
</body>
</html>