diff --git a/.env.example b/.env.example index eaf1d42c..af80e2f2 100644 --- a/.env.example +++ b/.env.example @@ -63,11 +63,11 @@ NEXT_PUBLIC_CHARACTER_PRESET4="" NEXT_PUBLIC_CHARACTER_PRESET5="" # 選択するVRMモデルのパス / Path to the selected VRM model -NEXT_PUBLIC_SELECTED_VRM_PATH="/vrm/default.vrm" +NEXT_PUBLIC_SELECTED_VRM_PATH="/vrm/nikechan_v2.vrm" # 選択するLive2Dモデルのモデルファイルのパス / # Path to the selected Live2D model file -NEXT_PUBLIC_SELECTED_LIVE2D_PATH="/live2d/modername/model3.json" +NEXT_PUBLIC_SELECTED_LIVE2D_PATH="/live2d/nike01/nike01.model3.json" # Live2D感情設定(カンマ区切りで複数指定可能) / # Live2D emotion settings (multiple can be specified with commas) @@ -356,6 +356,10 @@ NEXT_PUBLIC_YOUTUBE_LIVE_ID="" # Set the initial state of slide mode (true/false) NEXT_PUBLIC_SLIDE_MODE="false" +# OBS Studio Settings +NEXT_PUBLIC_OBS_WEBSOCKET_URL=ws://obs:4455 +NEXT_PUBLIC_OBS_WEBSOCKET_PASSWORD="obswebsocket" + #=============================================================================== # その他の設定 / Other Settings #=============================================================================== diff --git a/docker-compose.obs-url-recorder.override.yml b/docker-compose.obs-url-recorder.override.yml new file mode 100644 index 00000000..c33c0da3 --- /dev/null +++ b/docker-compose.obs-url-recorder.override.yml @@ -0,0 +1,24 @@ +services: + obs: + image: ghcr.io/stealthinu/obs-url-recorder:master + environment: + OBS_BROWSER_URL: http://app:3000/?slide=demo&autoplay=true + ports: + - "5900:5900" # VNC port + volumes: + - ~/aituberkit-output:/home/obsuser/output + + aivisspeech-engine: + image: ghcr.io/aivis-project/aivisspeech-engine:nvidia-latest + restart: always + ports: + - "10101:10101" + deploy: + resources: + reservations: + devices: + - driver: nvidia + count: all + capabilities: [gpu] + volumes: + - ~/.local/share/AivisSpeech-Engine:/home/user/.local/share/AivisSpeech-Engine-Dev diff --git a/docs/obs_recorder.md b/docs/obs_recorder.md new file mode 100644 index 00000000..b55833d5 --- /dev/null +++ b/docs/obs_recorder.md @@ -0,0 +1,20 @@ +# OBS連携機能 + +AITuberKitはOBS Studioと連携してスライドショーの録画を自動化できます。以下の環境変数を設定することで、OBS WebSocketへの接続設定を行います。 + +``` +# .envファイルに追加 +NEXT_PUBLIC_OBS_WEBSOCKET_URL=ws://localhost:4455 +NEXT_PUBLIC_OBS_WEBSOCKET_PASSWORD=yourpassword +``` + +設定方法: +1. OBS Studioを起動し、「ツール」→「WebSocket Server Settings」を開きます +2. 「Enable WebSocket Server」にチェックを入れます +3. 必要に応じてパスワードを設定します(パスワードを設定しない場合は空白にします) +4. 上記の環境変数を.envファイルに追加し、URLとパスワードを設定します + +スライドの自動再生URLパラメータ: +``` +http://localhost:3000?slide=スライド名&autoplay=true +``` diff --git a/package-lock.json b/package-lock.json index 20934686..9c4aa3d8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -41,6 +41,7 @@ "i18next": "^23.6.0", "lodash": "^4.17.21", "next": "^14.2.5", + "obs-websocket-js": "^5.0.6", "ollama-ai-provider": "^0.13.0", "openai": "^4.89.0", "pdfjs-dist": "^4.5.136", @@ -1691,6 +1692,15 @@ } } }, + "node_modules/@msgpack/msgpack": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@msgpack/msgpack/-/msgpack-2.8.0.tgz", + "integrity": "sha512-h9u4u/jiIRKbq25PM+zymTyW6bhTzELvOoUd+AvYriWOAKpLGnIamaET3pnHYoI5iYphAHBI4ayx0MehR+VVPQ==", + "license": "ISC", + "engines": { + "node": ">= 10" + } + }, "node_modules/@napi-rs/canvas": { "version": "0.1.68", "resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-0.1.68.tgz", @@ -4925,6 +4935,12 @@ "node": "*" } }, + "node_modules/crypto-js": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-4.2.0.tgz", + "integrity": "sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==", + "license": "MIT" + }, "node_modules/css-box-model": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/css-box-model/-/css-box-model-1.2.1.tgz", @@ -7667,6 +7683,15 @@ "resolved": "https://registry.npmjs.org/ismobilejs/-/ismobilejs-1.1.1.tgz", "integrity": "sha512-VaFW53yt8QO61k2WJui0dHf4SlL8lxBofUuUmwBo0ljPk0Drz2TiuDW4jo3wDcv41qy/SxrJ+VAzJ/qYqsmzRw==" }, + "node_modules/isomorphic-ws": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/isomorphic-ws/-/isomorphic-ws-5.0.0.tgz", + "integrity": "sha512-muId7Zzn9ywDsyXgTIafTry2sV3nySZeUDe6YedVd1Hvuuep5AsIlqK+XefWpYTyJG5e503F2xIuT2lcU6rCSw==", + "license": "MIT", + "peerDependencies": { + "ws": "*" + } + }, "node_modules/iterator.prototype": { "version": "1.1.5", "resolved": "https://registry.npmjs.org/iterator.prototype/-/iterator.prototype-1.1.5.tgz", @@ -8944,6 +8969,42 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/obs-websocket-js": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/obs-websocket-js/-/obs-websocket-js-5.0.6.tgz", + "integrity": "sha512-tHRq8py1XrdlMhSV+N/KTGHqoRIcsBBXbjcA9ex21k128iQ7YXjxzNWs3s9gidP1tEcIyVuJu8FnPgrSBWGm0Q==", + "license": "MIT", + "dependencies": { + "@msgpack/msgpack": "^2.7.1", + "crypto-js": "^4.1.1", + "debug": "^4.3.2", + "eventemitter3": "^5.0.1", + "isomorphic-ws": "^5.0.0", + "type-fest": "^3.11.0", + "ws": "^8.13.0" + }, + "engines": { + "node": ">16.0" + } + }, + "node_modules/obs-websocket-js/node_modules/eventemitter3": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz", + "integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==", + "license": "MIT" + }, + "node_modules/obs-websocket-js/node_modules/type-fest": { + "version": "3.13.1", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-3.13.1.tgz", + "integrity": "sha512-tLq3bSNx+xSpwvAJnzrK0Ep5CLNWjvFTOp71URMaAEWBfRb9nnJiBoUe0tF8bI4ZFO3omgBR6NvnbzVUT3Ly4g==", + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/ollama-ai-provider": { "version": "0.13.0", "resolved": "https://registry.npmjs.org/ollama-ai-provider/-/ollama-ai-provider-0.13.0.tgz", diff --git a/package.json b/package.json index 20a05269..aef05159 100644 --- a/package.json +++ b/package.json @@ -48,6 +48,7 @@ "i18next": "^23.6.0", "lodash": "^4.17.21", "next": "^14.2.5", + "obs-websocket-js": "^5.0.6", "ollama-ai-provider": "^0.13.0", "openai": "^4.89.0", "pdfjs-dist": "^4.5.136", diff --git a/src/components/menu.tsx b/src/components/menu.tsx index de98092d..e7618b11 100644 --- a/src/components/menu.tsx +++ b/src/components/menu.tsx @@ -50,6 +50,7 @@ export const Menu = () => { const showCapture = menuStore((s) => s.showCapture) const slidePlaying = slideStore((s) => s.isPlaying) const showAssistantText = settingsStore((s) => s.showAssistantText) + const isAutoplay = slideStore((s) => s.isAutoplay) const [showSettings, setShowSettings] = useState(false) const [showChatLog, setShowChatLog] = useState(false) @@ -197,132 +198,138 @@ export const Menu = () => { )} -
-
- {showControlPanel && ( - <> -
- setShowSettings(true)} - > -
-
- {showChatLog ? ( - setShowChatLog(false)} - /> - ) : ( - setShowChatLog(true)} - /> - )} -
- {!youtubeMode && - multiModalAIServices.includes( - selectAIService as multiModalAIServiceKey - ) && ( - <> -
- -
-
- -
-
- imageFileInputRef.current?.click()} - /> - { - const file = e.target.files?.[0] - if (file) { - const reader = new FileReader() - reader.onload = (e) => { - const imageUrl = e.target?.result as string - homeStore.setState({ modalImage: imageUrl }) - } - reader.readAsDataURL(file) - } - }} - /> -
- - )} - {youtubeMode && ( -
+ {!isAutoplay && ( +
+
+ {showControlPanel && ( + <> +
- settingsStore.setState({ - youtubePlaying: !youtubePlaying, - }) - } - /> + onClick={() => setShowSettings(true)} + >
- )} - {slideMode && ( -
- - menuStore.setState({ slideVisible: !slideVisible }) - } - disabled={slidePlaying} - /> +
+ {showChatLog ? ( + setShowChatLog(false)} + /> + ) : ( + setShowChatLog(true)} + /> + )}
- )} - - )} + {!youtubeMode && + multiModalAIServices.includes( + selectAIService as multiModalAIServiceKey + ) && ( + <> +
+ +
+
+ +
+
+ imageFileInputRef.current?.click()} + /> + { + const file = e.target.files?.[0] + if (file) { + const reader = new FileReader() + reader.onload = (e) => { + const imageUrl = e.target?.result as string + homeStore.setState({ modalImage: imageUrl }) + } + reader.readAsDataURL(file) + } + }} + /> +
+ + )} + {youtubeMode && ( +
+ + settingsStore.setState({ + youtubePlaying: !youtubePlaying, + }) + } + /> +
+ )} + {slideMode && ( +
+ + menuStore.setState({ slideVisible: !slideVisible }) + } + disabled={slidePlaying} + /> +
+ )} + + )} +
-
+ )}
{slideMode && slideVisible && }
- {showChatLog && } - {showSettings && setShowSettings(false)} />} - {!showChatLog && - assistantMessage && - (!slideMode || !slideVisible) && - showAssistantText && } - {showWebcam && navigator.mediaDevices && } - {showCapture && } - {showPermissionModal && ( -
-
-

カメラの使用を許可してください。

- -
-
+ {!isAutoplay && ( + <> + {showChatLog && } + {showSettings && setShowSettings(false)} />} + {!showChatLog && + assistantMessage && + (!slideMode || !slideVisible) && + showAssistantText && } + {showWebcam && navigator.mediaDevices && } + {showCapture && } + {showPermissionModal && ( +
+
+

カメラの使用を許可してください。

+ +
+
+ )} + )} void nextSlide: () => void toggleIsPlaying: () => void + obsConnected?: boolean + isRecording?: boolean } const SlideControls: React.FC = ({ @@ -17,15 +19,11 @@ const SlideControls: React.FC = ({ prevSlide, nextSlide, toggleIsPlaying, + obsConnected, + isRecording, }) => { return ( -
+
= ({ className="bg-primary hover:bg-primary-hover disabled:bg-primary-disabled text-white rounded-2xl py-2 px-4 text-center mx-16" />
+ + {/* OBS接続状態と録画状態を表示 */} + {obsConnected !== undefined && ( +
+ + OBS {obsConnected ? '接続中' : '未接続'} + + {obsConnected && isRecording !== undefined && ( +
+ + + {isRecording ? '🔴 録画中' : '⚪ 録画停止'} + +
+ )} +
+ )}
) } diff --git a/src/components/slides.tsx b/src/components/slides.tsx index adfcff42..f71f0a12 100644 --- a/src/components/slides.tsx +++ b/src/components/slides.tsx @@ -1,9 +1,18 @@ -import React, { useEffect, useState, useCallback } from 'react' +import React, { useEffect, useState, useCallback, useRef } from 'react' import slideStore from '@/features/stores/slide' import homeStore from '@/features/stores/home' import { speakMessageHandler } from '@/features/chat/handlers' import SlideContent from './slideContent' import SlideControls from './slideControls' +import * as OBSWebSocketModule from 'obs-websocket-js' + +const OBSWebSocket = OBSWebSocketModule.default || OBSWebSocketModule; + +// OBS接続用の設定を環境変数から取得 +const obsConfig = { + url: process.env.NEXT_PUBLIC_OBS_WEBSOCKET_URL || 'ws://localhost:4455', + password: process.env.NEXT_PUBLIC_OBS_WEBSOCKET_PASSWORD || '', // パスワードを.envから取得 +} interface SlidesProps { markdown: string @@ -20,147 +29,471 @@ const Slides: React.FC = ({ markdown }) => { const isPlaying = slideStore((state) => state.isPlaying) const currentSlide = slideStore((state) => state.currentSlide) const selectedSlideDocs = slideStore((state) => state.selectedSlideDocs) + const isAutoplay = slideStore((state) => state.isAutoplay) const chatProcessingCount = homeStore((s) => s.chatProcessingCount) const [slideCount, setSlideCount] = useState(0) + + // スライドの準備状態を追跡 + const [slidesReady, setSlidesReady] = useState(false) + + // 動画の再生状態を追跡 + const [videoPlaying, setVideoPlaying] = useState(false) + // 現在のスライドの動画要素を追跡するref + const currentVideosRef = useRef([]) + // 自動再生の開始状態を追跡するためのRef + const playbackStartedRef = useRef(false); + + // OBS接続関連の状態を追加 + const [obs, setObs] = useState(null); + const [obsConnected, setObsConnected] = useState(false); + const [isRecording, setIsRecording] = useState(false); + + // コンポーネントマウント時にOBSインスタンスを作成 + useEffect(() => { + const obsInstance = new OBSWebSocket(); + setObs(obsInstance); + }, []); + + // OBS録画状態を確認する関数 + const checkRecordingStatus = useCallback(async () => { + if (!obs || !obsConnected) return; + + try { + // OBSの録画状態を取得 + const { outputActive } = await obs.call('GetRecordStatus'); + // 現在の状態と異なる場合のみ更新(無駄なレンダリングを避ける) + if (outputActive !== isRecording) { + console.log(`録画状態を更新: ${outputActive ? '録画中' : '録画停止'}`); + setIsRecording(outputActive); + } + } catch (error) { + console.error('録画状態の取得に失敗しました:', error); + } + }, [obs, obsConnected, isRecording]); + + // OBSに接続する関数 + const connectToOBS = useCallback(async () => { + if (!obs) { + console.error('OBSWebSocketインスタンスが作成されていません'); + return; + } + + try { + console.log('OBS Studioに接続を試みています...'); + await obs.connect(obsConfig.url, obsConfig.password); + console.log('OBS Studio に接続しました'); + setObsConnected(true); + + // 接続後すぐに録画状態を確認 + await checkRecordingStatus(); + + // OBSからのイベント通知を設定 + obs.on('RecordStateChanged', (event: { outputActive: boolean }) => { + console.log(`OBSの録画状態が変更されました: ${event.outputActive ? '録画中' : '録画停止'}`); + setIsRecording(event.outputActive); + }); + } catch (error) { + console.error('OBS Studioへの接続に失敗しました:', error); + setObsConnected(false); + } + }, [obs, checkRecordingStatus]); + + // 録画を開始する関数 + const startRecording = useCallback(async () => { + if (!obs || !obsConnected) return; + + try { + // 録画状態を最新に更新 + await checkRecordingStatus(); + + // 録画中でない場合のみ録画開始 + if (!isRecording) { + await obs.call('StartRecord'); + setIsRecording(true); + console.log('録画を開始しました'); + } else { + console.log('既に録画中です'); + } + } catch (error) { + console.error('録画開始に失敗しました:', error); + } + }, [obs, obsConnected, isRecording, checkRecordingStatus]); + + // 録画を停止する関数 + const stopRecording = useCallback(async () => { + if (!obs || !obsConnected) return; + + try { + // 録画状態を最新に更新 + await checkRecordingStatus(); + + // 録画中の場合のみ録画停止 + if (isRecording) { + await obs.call('StopRecord'); + setIsRecording(false); + console.log('録画を停止しました'); + } else { + console.log('録画が停止されていません'); + } + } catch (error) { + console.error('録画停止に失敗しました:', error); + } + }, [obs, obsConnected, isRecording, checkRecordingStatus]); + + // 定期的に録画状態を確認 + useEffect(() => { + if (!obsConnected) return; + + // 5秒ごとに録画状態を確認 + const interval = setInterval(() => { + checkRecordingStatus(); + }, 5000); + + return () => clearInterval(interval); + }, [obsConnected, checkRecordingStatus]); + + // コンポーネントマウント時にOBSに接続 + useEffect(() => { + // obsインスタンスが作成されたら接続を試みる + if (obs) { + connectToOBS(); + } + + // コンポーネントアンマウント時に接続を切断 + return () => { + if (obs && obsConnected) { + // 録画中なら停止 + if (isRecording) { + stopRecording(); + } + // 接続を切断 + try { + obs.disconnect(); + console.log('OBS Studioとの接続を切断しました'); + } catch (error) { + console.error('OBS Studioとの接続切断に失敗しました:', error); + } + } + } + }, [connectToOBS, obs, obsConnected, isRecording, stopRecording]); + + // 全てのビデオの再生状態をチェックする関数 + const checkAllVideosEnded = useCallback(() => { + if (currentVideosRef.current.length === 0) { + return true; // ビデオがない場合は終了している判定 + } + + // すべてのビデオが終了しているかチェック + return currentVideosRef.current.every(video => + video.ended || video.paused || video.currentTime >= video.duration - 0.5 + ); + }, []); + + // スライドの音声を読み上げる関数 + const readSlide = useCallback( + (slideIndex: number) => { + const getCurrentLines = () => { + try { + const scripts = require( + `../../public/slides/${selectedSlideDocs}/scripts.json` + ) + const currentScript = scripts.find( + (script: { page: number }) => script.page === slideIndex + ) + return currentScript ? currentScript.line : '' + } catch (error) { + console.error(`スライド「${selectedSlideDocs}」のスクリプト読み込みに失敗しました:`, error); + return ''; + } + } + + const currentLines = getCurrentLines() + console.log(`スライド${slideIndex}を読み上げ: ${currentLines}`) + speakMessageHandler(currentLines) + }, + [selectedSlideDocs] + ) + // 現在表示しているスライドの動画要素を追跡 useEffect(() => { const currentMarpitContainer = document.querySelector('.marpit') - if (currentMarpitContainer) { - const slides = currentMarpitContainer.querySelectorAll(':scope > svg') - slides.forEach((slide, i) => { - const svgElement = slide as SVGElement - if (i === currentSlide) { - svgElement.style.display = 'block' + if (!currentMarpitContainer) return + const slides = currentMarpitContainer.querySelectorAll(':scope > svg') + + slides.forEach((slide, i) => { + if (i === currentSlide) { + // 表示するスライド + slide.removeAttribute('hidden') + slide.setAttribute('style', 'display: block;') + + // 新しく表示されるスライド内の video を再生し、イベントリスナーを追加 + const videos = slide.querySelectorAll('video') as NodeListOf + const videoArray: HTMLVideoElement[] = []; + + if (videos.length > 0) { + console.log(`スライド${currentSlide}には${videos.length}個の動画があります`); + setVideoPlaying(true); } else { - svgElement.style.display = 'none' + setVideoPlaying(false); } - }) - } - }, [currentSlide, marpitContainer]) + + videos.forEach((video) => { + // ビデオの再生が終了したときのイベントリスナーを設定 + video.addEventListener('ended', () => { + console.log('動画の再生が終了しました'); + // すべての動画が終了したかチェック + if (checkAllVideosEnded()) { + console.log('すべての動画の再生が終了しました'); + setVideoPlaying(false); + } + }); + + // ビデオの再生開始 + video.play().catch((err) => { + console.warn('Video autoplay failed:', err) + // 自動再生に失敗した場合は、再生中でないと判断 + setVideoPlaying(false); + }); + + videoArray.push(video); + }); + + // 現在のビデオ要素を保存 + currentVideosRef.current = videoArray; + } else { + // 非表示にするスライド + slide.setAttribute('hidden', '') + slide.setAttribute('style', 'display: none;') + + // 非表示になったスライド内の video を停止 + // TODO: video一時停止するとplay()がエラーで動かないため一度流したら止めないで対応した + const videos = slide.querySelectorAll('video') as NodeListOf + videos.forEach((video) => { + //video.pause() + //video.currentTime = 0 // 最初に巻き戻したい場合は指定 + }) + } + }) + }, [currentSlide, marpitContainer, checkAllVideosEnded]) + // スライドの読み込み処理 useEffect(() => { const convertMarkdown = async () => { - const response = await fetch('/api/convertMarkdown', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ slideName: selectedSlideDocs }), - }) - const data = await response.json() - - // HTMLをパースしてmarpit要素を取得 - const parser = new DOMParser() - const doc = parser.parseFromString(data.html, 'text/html') - const marpitElement = doc.querySelector('.marpit') - setMarpitContainer(marpitElement) - - // スライド数を設定 - if (marpitElement) { - const slides = marpitElement.querySelectorAll(':scope > svg') - setSlideCount(slides.length) - - // 初期状態で最初のスライドを表示 + // 読み込み開始時は準備完了フラグをリセット + setSlidesReady(false); + + console.log(`スライド「${selectedSlideDocs}」の読み込みを開始します...`); + + try { + const response = await fetch('/api/convertMarkdown', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ slideName: selectedSlideDocs }), + }); + + if (!response.ok) { + throw new Error(`スライドの変換に失敗しました: ${response.statusText}`); + } + + const data = await response.json(); + + // HTMLをパースしてmarpit要素を取得 + const parser = new DOMParser(); + const doc = parser.parseFromString(data.html, 'text/html'); + const marpitElement = doc.querySelector('.marpit'); + + if (!marpitElement) { + throw new Error('スライドのHTMLが正しく生成されませんでした'); + } + + setMarpitContainer(marpitElement); + + // スライド数を設定 + const slides = marpitElement.querySelectorAll(':scope > svg'); + setSlideCount(slides.length); + console.log(`スライド数: ${slides.length}枚`); + + // 初期状態で最初のスライド以外は非表示にしておく slides.forEach((slide, i) => { if (i === 0) { - slide.removeAttribute('hidden') + slide.removeAttribute('hidden'); + slide.setAttribute('style', 'display: block;'); } else { - slide.setAttribute('hidden', '') + slide.setAttribute('hidden', ''); + slide.setAttribute('style', 'display: none;'); } - }) - } + }); - // CSSを動的に適用 - const styleElement = document.createElement('style') - styleElement.textContent = data.css - document.head.appendChild(styleElement) + // CSSを動的に適用 + const styleElement = document.createElement('style'); + styleElement.textContent = data.css; + document.head.appendChild(styleElement); - return () => { - document.head.removeChild(styleElement) + // スライドの準備が完了したことを通知 + console.log(`スライド「${selectedSlideDocs}」の読み込みが完了しました`); + setSlidesReady(true); + + return () => { + document.head.removeChild(styleElement); + }; + } catch (error) { + console.error('スライドの読み込み中にエラーが発生しました:', error); + setSlidesReady(false); } - } + }; - convertMarkdown() - }, [selectedSlideDocs]) + convertMarkdown(); + }, [selectedSlideDocs]); + // カスタムCSSの適用 useEffect(() => { // カスタムCSSを適用 const customStyle = ` div.marpit > svg > foreignObject > section { padding: 2em; } - ` - const styleElement = document.createElement('style') - styleElement.textContent = customStyle - document.head.appendChild(styleElement) + `; + const styleElement = document.createElement('style'); + styleElement.textContent = customStyle; + document.head.appendChild(styleElement); // コンポーネントのアンマウント時にスタイルを削除 return () => { - document.head.removeChild(styleElement) + document.head.removeChild(styleElement); + }; + }, []); + + // スライドが準備完了したときに、自動再生モードであれば再生を開始 + useEffect(() => { + // すでに再生を開始している場合は何もしない + if (slidesReady && isAutoplay && !isPlaying && !playbackStartedRef.current) { + console.log('スライドの準備が完了し、自動再生モードが有効なため、再生を開始します'); + // 再生開始フラグを設定 + playbackStartedRef.current = true; + + // 現在のスライドを明示的に0に設定 + slideStore.setState({ currentSlide: 0 }); + + // 少し遅延を入れてから再生開始(スライドが確実に表示されてから) + setTimeout(() => { + // 状態が変わっていないことを確認 + if (isAutoplay && !slideStore.getState().isPlaying) { + console.log('スライド0の音声を読み上げ、再生を開始します'); + readSlide(0); + slideStore.setState({ isPlaying: true }); + } + }, 1000); } - }, []) + }, [slidesReady, isAutoplay, isPlaying, readSlide]); - const readSlide = useCallback( - (slideIndex: number) => { - const getCurrentLines = () => { - const scripts = require( - `../../public/slides/${selectedSlideDocs}/scripts.json` - ) - const currentScript = scripts.find( - (script: { page: number }) => script.page === slideIndex - ) - return currentScript ? currentScript.line : '' + // 最後のスライドに到達時の処理を強化 + useEffect(() => { + // 最後のスライドに達し、かつ音声と動画が終了した場合にisPlayingをfalseに設定 + if (currentSlide === slideCount - 1 && chatProcessingCount === 0 && !videoPlaying) { + console.log('最後のスライドの再生が終了しました。自動再生を停止します'); + + // 再生状態と自動再生状態を停止に設定 + slideStore.setState({ + isPlaying: false, + // 自動再生モードも無効化して再ループを防止 + isAutoplay: false + }); + + // 再生フラグもリセット + playbackStartedRef.current = false; + + // 録画中なら停止 + if (obsConnected && isRecording) { + stopRecording(); } + } + }, [currentSlide, slideCount, chatProcessingCount, videoPlaying, obsConnected, isRecording, stopRecording]); + + // コンポーネントのアンマウント時や選択スライドの変更時に自動再生状態をリセット + useEffect(() => { + // 選択スライドが変更された場合、再生開始フラグをリセット + playbackStartedRef.current = false; + + // コンポーネントのアンマウント時や選択スライドの変更時に実行されるクリーンアップ関数 + return () => { + playbackStartedRef.current = false; + + // 自動再生状態をリセット(コンポーネントのアンマウント時のみ) + if (slideStore.getState().isAutoplay) { + slideStore.setState({ + isPlaying: false, + }); + } + }; + }, [selectedSlideDocs]); - const currentLines = getCurrentLines() - console.log(currentLines) - speakMessageHandler(currentLines) - }, - [selectedSlideDocs] - ) - + // 次のスライドに進む関数 const nextSlide = useCallback(() => { slideStore.setState((state) => { - const newSlide = Math.min(state.currentSlide + 1, slideCount - 1) + const newSlide = Math.min(state.currentSlide + 1, slideCount - 1); + + // 再生中の場合は音声を読み上げる if (isPlaying) { - readSlide(newSlide) + readSlide(newSlide); } - return { currentSlide: newSlide } - }) - }, [isPlaying, readSlide, slideCount]) - - useEffect(() => { - // 最後のスライドに達した場合、isPlayingをfalseに設定 - if (currentSlide === slideCount - 1 && chatProcessingCount === 0) { - slideStore.setState({ isPlaying: false }) - } - }, [currentSlide, slideCount, chatProcessingCount]) + + return { currentSlide: newSlide }; + }); + }, [isPlaying, readSlide, slideCount]); + // 前のスライドに戻る関数 const prevSlide = useCallback(() => { slideStore.setState((state) => ({ currentSlide: Math.max(state.currentSlide - 1, 0), - })) - }, []) + })); + }, []); - const toggleIsPlaying = () => { - const newIsPlaying = !isPlaying - slideStore.setState({ - isPlaying: newIsPlaying, - }) + // 再生/停止を切り替える関数 + const toggleIsPlaying = useCallback(() => { + const newIsPlaying = !isPlaying; + + // 状態を更新 + slideStore.setState({ isPlaying: newIsPlaying }); + + // 再生開始時には現在のスライドの音声を読み上げる if (newIsPlaying) { - readSlide(currentSlide) + console.log(`手動再生: スライド${currentSlide}の音声を読み上げます`); + readSlide(currentSlide); } - } + }, [isPlaying, currentSlide, readSlide]); + // isPlayingの変更を監視して録画を制御 useEffect(() => { - if ( - chatProcessingCount === 0 && - isPlaying && - currentSlide < slideCount - 1 - ) { - nextSlide() + if (isPlaying && obsConnected && !isRecording) { + // スライドショー開始時に録画開始 + startRecording(); + } else if (!isPlaying && obsConnected && isRecording) { + // スライドショー終了時に録画停止 + stopRecording(); + } + }, [isPlaying, obsConnected, isRecording, startRecording, stopRecording]); + + // 音声ナレーションと動画が終了したら次のスライドへ進む + useEffect(() => { + // 再生中で、最後のスライドではなく、音声と動画の両方が終了した場合 + if (isPlaying && currentSlide < slideCount - 1 && chatProcessingCount === 0 && !videoPlaying) { + // すぐに次に進むのではなく、少し間を取る + const timerId = setTimeout(() => { + // 状態をダブルチェック(タイマー実行までに状態が変わっている可能性がある) + if (slideStore.getState().isPlaying && + currentSlide < slideCount - 1 && + homeStore.getState().chatProcessingCount === 0 && + !videoPlaying) { + console.log('音声ナレーションと動画の両方が終了したため、次のスライドへ進みます'); + nextSlide(); + } + }, 500); + + return () => clearTimeout(timerId); } - }, [chatProcessingCount, isPlaying, nextSlide, currentSlide, slideCount]) + }, [chatProcessingCount, videoPlaying, isPlaying, currentSlide, slideCount, nextSlide]); // スライドの縦のサイズを70%に制限し、アスペクト比を維持 const calculateSlideSize = () => { @@ -199,6 +532,8 @@ const Slides: React.FC = ({ markdown }) => { >
+ {/* 自動再生モードでない場合のみコントロールを表示 */} + {!isAutoplay && (
= ({ markdown }) => { prevSlide={prevSlide} nextSlide={nextSlide} toggleIsPlaying={toggleIsPlaying} + obsConnected={obsConnected} + isRecording={isRecording} />
+ )}
) } diff --git a/src/features/stores/slide.ts b/src/features/stores/slide.ts index 211240a6..2729f3cf 100644 --- a/src/features/stores/slide.ts +++ b/src/features/stores/slide.ts @@ -5,6 +5,7 @@ interface SlideState { isPlaying: boolean currentSlide: number selectedSlideDocs: string + isAutoplay: boolean } const slideStore = create()( @@ -13,6 +14,7 @@ const slideStore = create()( isPlaying: false, currentSlide: 0, selectedSlideDocs: '', + isAutoplay: false, }), { name: 'aitube-kit-slide', diff --git a/src/pages/_app.tsx b/src/pages/_app.tsx index f1a8b175..48cb4bdd 100644 --- a/src/pages/_app.tsx +++ b/src/pages/_app.tsx @@ -2,15 +2,20 @@ import '@charcoal-ui/icons' import type { AppProps } from 'next/app' import React, { useEffect } from 'react' import { Analytics } from '@vercel/analytics/react' +import { useRouter } from 'next/router' import { isLanguageSupported } from '@/features/constants/settings' import homeStore from '@/features/stores/home' import settingsStore from '@/features/stores/settings' +import slideStore from '@/features/stores/slide' +import menuStore from '@/features/stores/menu' import '@/styles/globals.css' import migrateStore from '@/utils/migrateStore' import i18n from '../lib/i18n' export default function App({ Component, pageProps }: AppProps) { + const router = useRouter() + useEffect(() => { const hs = homeStore.getState() const ss = settingsStore.getState() @@ -37,6 +42,52 @@ export default function App({ Component, pageProps }: AppProps) { homeStore.setState({ userOnboarded: true }) }, []) + // URLパラメータからスライド関連の設定を取得 + useEffect(() => { + // クエリパラメータが準備できたら処理 + if (!router.isReady) return + + const { slide, autoplay } = router.query + + // スライド名が指定された場合 + if (typeof slide === 'string' && slide) { + console.log(`スライドを自動選択: ${slide}`) + + // スライドモードを有効化 + settingsStore.setState({ slideMode: true }) + + // 強制的に初期状態にリセット + slideStore.setState({ + isPlaying: false, + currentSlide: 0, + isAutoplay: false + }); + + // スライドを選択(この時点ではまだロード中) + slideStore.setState({ selectedSlideDocs: slide }) + + // スライドを表示 + menuStore.setState({ slideVisible: true }) + + // 自動再生が指定された場合 + if (autoplay === 'true') { + console.log('スライドの自動再生モードを設定します') + + // イントロダクションを非表示にする + homeStore.setState({ showIntroduction: false }) + + // 自動再生モードを設定 + slideStore.setState({ isAutoplay: true }) + + // 再生自体はslides.tsxのマークダウン変換完了後のタイミングで行う + // そのため、ここではisPlayingの設定は行わない + } else { + // 自動再生ではない場合、明示的にフラグをオフに + slideStore.setState({ isAutoplay: false }) + } + } + }, [router.isReady, router.query]) + return ( <>