const elements = { start: document.querySelector("#startShare"), stop: document.querySelector("#stopShare"), loadSample: document.querySelector("#loadSample"), toggleRig: document.querySelector("#toggleRig"), toggleScreen: document.querySelector("#toggleScreen"), openChatGPT: document.querySelector("#openChatGPT"), video: document.querySelector("#sharedScreen"), empty: document.querySelector("#emptyState"), frame: document.querySelector("#rigFrame"), shareStatus: document.querySelector("#shareStatus"), audioStatus: document.querySelector("#audioStatus"), rigStatus: document.querySelector("#rigStatus"), meter: document.querySelector("#mouthMeter"), value: document.querySelector("#mouthValue"), sensitivity: document.querySelector("#sensitivity"), message: document.querySelector("#message"), }; let displayStream = null; let audioContext = null; let analyser = null; let samples = null; let animationId = null; let smoothedLevel = 0; let rigMouthInput = null; let rigAvailable = false; let rigControlsVisible = false; function setMessage(text, error = false) { elements.message.textContent = text; elements.message.classList.toggle("error", error); } async function loadRigStatus() { try { const response = await fetch("/api/status"); const status = await response.json(); if (!status.animeRigAvailable) { elements.rigStatus.textContent = "未配置"; elements.frame.hidden = true; elements.toggleRig.disabled = true; setMessage(`${status.expectedPath} にAnime2.5DRigのindex.htmlを配置してください。音声検証は実行できます。`); return; } elements.rigStatus.textContent = "利用可能"; rigAvailable = true; elements.frame.src = status.animeRigUrl; elements.frame.hidden = false; elements.toggleRig.disabled = false; elements.loadSample.disabled = false; } catch (error) { elements.rigStatus.textContent = "確認失敗"; setMessage(`Anime2.5DRigの状態確認に失敗しました: ${error.message}`, true); } } function findRigMouthInput() { try { const doc = elements.frame.contentDocument; if (!doc) return null; const knownMouthInput = doc.querySelector("#pMouthOpen"); if (knownMouthInput) return knownMouthInput; const sliders = [...doc.querySelectorAll('input[type="range"]')]; return sliders.find((input) => { const area = input.parentElement?.parentElement?.textContent || input.parentElement?.textContent || ""; return area.includes("口") && area.includes("開き"); }) || null; } catch { return null; } } function applyRigDisplayMode() { const doc = elements.frame.contentDocument; if (!doc) return; const styleId = "gpt-voice-avatar-only"; doc.getElementById(styleId)?.remove(); elements.frame.classList.toggle("controls-visible", rigControlsVisible); if (rigControlsVisible) return; const style = doc.createElement("style"); style.id = styleId; style.textContent = ` html, body, #main, #stage { width: 100% !important; height: 100% !important; min-width: 0 !important; min-height: 0 !important; margin: 0 !important; padding: 0 !important; overflow: hidden !important; background: transparent !important; } header, #panel, #fps, #rigInfo, #drop, #readmeOverlay { display: none !important; } #stage { display: flex !important; align-items: center !important; justify-content: center !important; } #cv { max-width: 100% !important; max-height: 100% !important; } `; doc.head.appendChild(style); } async function loadSampleRig() { if (!rigAvailable || !elements.frame.contentDocument) { setMessage("Anime2.5DRigがまだ利用できません。", true); return; } try { const doc = elements.frame.contentDocument; const sampleButton = [...doc.querySelectorAll("button")].find((button) => button.textContent?.includes("sample.psd") ); if (sampleButton) { sampleButton.click(); setMessage("Anime2.5DRigのsample.psdを読み込んでいます。"); return; } const fileInput = doc.querySelector('input[type="file"]'); if (!fileInput) throw new Error("PSDファイル入力が見つかりません"); const sampleUrl = new URL("sample.psd", elements.frame.contentWindow.location.href); const response = await fetch(sampleUrl); if (!response.ok) throw new Error(`sample.psdの取得に失敗しました (${response.status})`); const file = new File([await response.blob()], "sample.psd", { type: "image/vnd.adobe.photoshop" }); const transfer = new DataTransfer(); transfer.items.add(file); fileInput.files = transfer.files; fileInput.dispatchEvent(new Event("change", { bubbles: true })); setMessage("/Anime2.5DRig/sample.psdを読み込みました。"); } catch (error) { setMessage(`サンプルキャラクターを読み込めませんでした: ${error.message}`, true); } } function updateRigMouth(level) { if (!rigMouthInput || !rigMouthInput.isConnected) { rigMouthInput = findRigMouthInput(); } if (!rigMouthInput) return; const min = Number(rigMouthInput.min || 0); const max = Number(rigMouthInput.max || 1); rigMouthInput.value = String(min + (max - min) * level); rigMouthInput.dispatchEvent(new Event("input", { bubbles: true })); } function renderAudioLevel() { if (!analyser || !samples) return; analyser.getFloatTimeDomainData(samples); let energy = 0; for (const sample of samples) energy += sample * sample; const rms = Math.sqrt(energy / samples.length); const sensitivity = Number(elements.sensitivity.value); const gated = Math.max(0, rms - 0.008); const target = Math.min(1, gated * 18 * sensitivity); const smoothing = target > smoothedLevel ? 0.42 : 0.14; smoothedLevel += (target - smoothedLevel) * smoothing; elements.meter.value = smoothedLevel; elements.value.value = smoothedLevel.toFixed(2); updateRigMouth(smoothedLevel); animationId = requestAnimationFrame(renderAudioLevel); } async function startShare() { if (!navigator.mediaDevices?.getDisplayMedia) { setMessage("このブラウザは画面共有APIに対応していません。デスクトップ版Chromium系ブラウザで試してください。", true); return; } try { displayStream = await navigator.mediaDevices.getDisplayMedia({ video: { frameRate: { ideal: 30, max: 30 } }, audio: true, preferCurrentTab: false, selfBrowserSurface: "exclude", surfaceSwitching: "include", }); const videoTrack = displayStream.getVideoTracks()[0]; const audioTrack = displayStream.getAudioTracks()[0]; elements.video.srcObject = displayStream; elements.video.style.display = "block"; elements.empty.style.display = "none"; elements.shareStatus.textContent = videoTrack?.label || "共有中"; elements.start.disabled = true; elements.stop.disabled = false; elements.toggleScreen.disabled = false; videoTrack?.addEventListener("ended", stopShare, { once: true }); if (!audioTrack) { elements.audioStatus.textContent = "取得できませんでした"; setMessage("音声トラックがありません。共有選択画面でChatGPTのタブを選び、「タブの音声を共有」を有効にしてください。", true); return; } elements.audioStatus.textContent = audioTrack.label || "取得済み"; audioContext = new AudioContext(); await audioContext.resume(); const audioOnlyStream = new MediaStream([audioTrack]); const source = audioContext.createMediaStreamSource(audioOnlyStream); analyser = audioContext.createAnalyser(); analyser.fftSize = 1024; analyser.smoothingTimeConstant = 0; samples = new Float32Array(analyser.fftSize); source.connect(analyser); setMessage("音声トラックを取得しました。ChatGPTに応答させ、推定口開度が動くことを確認してください。"); renderAudioLevel(); } catch (error) { if (error.name === "NotAllowedError") { setMessage("画面共有がキャンセルされました。", false); } else { setMessage(`画面共有を開始できませんでした: ${error.message}`, true); } } } function stopShare() { if (animationId) cancelAnimationFrame(animationId); animationId = null; analyser = null; samples = null; smoothedLevel = 0; updateRigMouth(0); elements.meter.value = 0; elements.value.value = "0.00"; if (displayStream) { for (const track of displayStream.getTracks()) track.stop(); } displayStream = null; elements.video.srcObject = null; elements.video.style.display = "none"; elements.empty.style.display = "grid"; if (audioContext) audioContext.close().catch(() => {}); audioContext = null; elements.shareStatus.textContent = "停止済み"; elements.audioStatus.textContent = "停止済み"; elements.start.disabled = false; elements.stop.disabled = true; elements.toggleScreen.disabled = true; elements.toggleScreen.textContent = "共有画面を隠す"; } elements.start.addEventListener("click", startShare); elements.stop.addEventListener("click", stopShare); elements.loadSample.addEventListener("click", loadSampleRig); elements.toggleRig.addEventListener("click", () => { rigControlsVisible = !rigControlsVisible; applyRigDisplayMode(); elements.toggleRig.textContent = rigControlsVisible ? "設定UIを隠す" : "設定UIを表示"; }); elements.toggleScreen.addEventListener("click", () => { const hidden = elements.video.style.display !== "none"; elements.video.style.display = hidden ? "none" : "block"; elements.empty.style.display = "none"; elements.toggleScreen.textContent = hidden ? "共有画面を表示" : "共有画面を隠す"; }); elements.openChatGPT.addEventListener("click", () => { window.open("https://chatgpt.com/", "_blank", "noopener,noreferrer"); }); elements.frame.addEventListener("load", () => { rigMouthInput = findRigMouthInput(); elements.rigStatus.textContent = rigMouthInput ? "利用可能・口制御検出" : "利用可能・口制御未検出"; loadSampleRig(); applyRigDisplayMode(); }); window.addEventListener("beforeunload", stopShare); loadRigStatus();