Spaces:
Sleeping
Sleeping
| <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> | |