From 52c428fdb8a4cb0e9656d0c05bb82ea7b8fb7ad7 Mon Sep 17 00:00:00 2001 From: Satoh Kiyoshi Date: Tue, 7 Jan 2025 00:47:58 +0900 Subject: [PATCH 01/23] =?UTF-8?q?=E5=8B=95=E7=94=BB=E3=82=92=E3=83=9A?= =?UTF-8?q?=E3=83=BC=E3=82=B8=E3=81=8C=E8=A1=A8=E7=A4=BA=E3=81=95=E3=82=8C?= =?UTF-8?q?=E3=81=9F=E3=82=BF=E3=82=A4=E3=83=9F=E3=83=B3=E3=82=B0=E3=81=A7?= =?UTF-8?q?=E5=86=8D=E7=94=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/slides.tsx | 46 +++++++++++++++++++++++++++++---------- 1 file changed, 34 insertions(+), 12 deletions(-) diff --git a/src/components/slides.tsx b/src/components/slides.tsx index a168168b..e12da342 100644 --- a/src/components/slides.tsx +++ b/src/components/slides.tsx @@ -25,17 +25,37 @@ const Slides: React.FC = ({ markdown }) => { 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' - } else { - svgElement.style.display = 'none' - } - }) - } + 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 + videos.forEach((video) => { + //video.muted = true + video.play().catch((err) => { + console.warn('Video autoplay failed:', err) + }) + }) + } 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]) useEffect(() => { @@ -60,12 +80,14 @@ const Slides: React.FC = ({ markdown }) => { const slides = marpitElement.querySelectorAll(':scope > svg') setSlideCount(slides.length) - // 初期状態で最初のスライドを表示 + // 初期状態で最初のスライド以外は非表示にしておく slides.forEach((slide, i) => { if (i === 0) { slide.removeAttribute('hidden') + slide.setAttribute('style', 'display: block;') } else { slide.setAttribute('hidden', '') + slide.setAttribute('style', 'display: none;') } }) } From 4ddcab0007ecad24df7de0a3431064be095561e1 Mon Sep 17 00:00:00 2001 From: SATOH Kiyoshi Date: Tue, 7 Jan 2025 10:15:14 +0900 Subject: [PATCH 02/23] =?UTF-8?q?=E5=A4=89=E6=9B=B4=E3=81=AA=E3=81=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- public/live2d/nike01/items_pinned_to_model.json | 0 public/live2d/nike01/nike01.8192/texture_00.png | Bin public/live2d/nike01/nike01.cdi3.json | 0 public/live2d/nike01/nike01.physics3.json | 0 4 files changed, 0 insertions(+), 0 deletions(-) mode change 100755 => 100644 public/live2d/nike01/items_pinned_to_model.json mode change 100755 => 100644 public/live2d/nike01/nike01.8192/texture_00.png mode change 100755 => 100644 public/live2d/nike01/nike01.cdi3.json mode change 100755 => 100644 public/live2d/nike01/nike01.physics3.json diff --git a/public/live2d/nike01/items_pinned_to_model.json b/public/live2d/nike01/items_pinned_to_model.json old mode 100755 new mode 100644 diff --git a/public/live2d/nike01/nike01.8192/texture_00.png b/public/live2d/nike01/nike01.8192/texture_00.png old mode 100755 new mode 100644 diff --git a/public/live2d/nike01/nike01.cdi3.json b/public/live2d/nike01/nike01.cdi3.json old mode 100755 new mode 100644 diff --git a/public/live2d/nike01/nike01.physics3.json b/public/live2d/nike01/nike01.physics3.json old mode 100755 new mode 100644 From 52d225a712e476ccfaf7d0dc7c6a837b309445ce Mon Sep 17 00:00:00 2001 From: SATOH Kiyoshi Date: Tue, 7 Jan 2025 10:15:35 +0900 Subject: [PATCH 03/23] =?UTF-8?q?Revert=20"=E5=A4=89=E6=9B=B4=E3=81=AA?= =?UTF-8?q?=E3=81=97"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit 4ddcab0007ecad24df7de0a3431064be095561e1. --- public/live2d/nike01/items_pinned_to_model.json | 0 public/live2d/nike01/nike01.8192/texture_00.png | Bin public/live2d/nike01/nike01.cdi3.json | 0 public/live2d/nike01/nike01.physics3.json | 0 4 files changed, 0 insertions(+), 0 deletions(-) mode change 100644 => 100755 public/live2d/nike01/items_pinned_to_model.json mode change 100644 => 100755 public/live2d/nike01/nike01.8192/texture_00.png mode change 100644 => 100755 public/live2d/nike01/nike01.cdi3.json mode change 100644 => 100755 public/live2d/nike01/nike01.physics3.json diff --git a/public/live2d/nike01/items_pinned_to_model.json b/public/live2d/nike01/items_pinned_to_model.json old mode 100644 new mode 100755 diff --git a/public/live2d/nike01/nike01.8192/texture_00.png b/public/live2d/nike01/nike01.8192/texture_00.png old mode 100644 new mode 100755 diff --git a/public/live2d/nike01/nike01.cdi3.json b/public/live2d/nike01/nike01.cdi3.json old mode 100644 new mode 100755 diff --git a/public/live2d/nike01/nike01.physics3.json b/public/live2d/nike01/nike01.physics3.json old mode 100644 new mode 100755 From fd186f5497d6e8937d93f8c501d8051009065900 Mon Sep 17 00:00:00 2001 From: SATOH Kiyoshi Date: Sun, 2 Mar 2025 18:11:23 +0900 Subject: [PATCH 04/23] =?UTF-8?q?obs=E3=81=B8start/stop=E9=80=81=E3=82=8B?= =?UTF-8?q?=EF=BC=88=E9=80=94=E4=B8=AD=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package-lock.json | 97 ++++++++++++++++++++++++++++++++ package.json | 1 + src/components/slideControls.tsx | 27 ++++++--- src/components/slides.tsx | 92 +++++++++++++++++++++++++++++- 4 files changed, 209 insertions(+), 8 deletions(-) diff --git a/package-lock.json b/package-lock.json index 8544599a..23155946 100644 --- a/package-lock.json +++ b/package-lock.json @@ -37,6 +37,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.68.4", "pdfjs-dist": "^4.5.136", @@ -1575,6 +1576,14 @@ } } }, + "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==", + "engines": { + "node": ">= 10" + } + }, "node_modules/@next/env": { "version": "14.2.22", "resolved": "https://registry.npmjs.org/@next/env/-/env-14.2.22.tgz", @@ -4320,6 +4329,11 @@ "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==" + }, "node_modules/cssesc": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", @@ -6977,6 +6991,14 @@ "integrity": "sha512-VaFW53yt8QO61k2WJui0dHf4SlL8lxBofUuUmwBo0ljPk0Drz2TiuDW4jo3wDcv41qy/SxrJ+VAzJ/qYqsmzRw==", "license": "MIT" }, + "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==", + "peerDependencies": { + "ws": "*" + } + }, "node_modules/iterator.prototype": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/iterator.prototype/-/iterator.prototype-1.1.2.tgz", @@ -8293,6 +8315,39 @@ "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==", + "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==" + }, + "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==", + "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", @@ -12337,6 +12392,11 @@ "integrity": "sha512-VqCoAKwv1HJdzZp36dDPxznz2JZgRjkVSSPHpCzk72G2N753F0HPKXjevdjxmzN6gir9bUGBgMD1SguWJIi11A==", "requires": {} }, + "@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==" + }, "@next/env": { "version": "14.2.22", "resolved": "https://registry.npmjs.org/@next/env/-/env-14.2.22.tgz", @@ -14318,6 +14378,11 @@ "resolved": "https://registry.npmjs.org/crypt/-/crypt-0.0.2.tgz", "integrity": "sha512-mCxBlsHFYh9C+HVpiEacem8FEBnMXgU9gy4zmNC+SXAZNB/1idgp/aulFJ4FgCi7GPEVbfyng092GqL2k2rmow==" }, + "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==" + }, "cssesc": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", @@ -16257,6 +16322,12 @@ "resolved": "https://registry.npmjs.org/ismobilejs/-/ismobilejs-1.1.1.tgz", "integrity": "sha512-VaFW53yt8QO61k2WJui0dHf4SlL8lxBofUuUmwBo0ljPk0Drz2TiuDW4jo3wDcv41qy/SxrJ+VAzJ/qYqsmzRw==" }, + "isomorphic-ws": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/isomorphic-ws/-/isomorphic-ws-5.0.0.tgz", + "integrity": "sha512-muId7Zzn9ywDsyXgTIafTry2sV3nySZeUDe6YedVd1Hvuuep5AsIlqK+XefWpYTyJG5e503F2xIuT2lcU6rCSw==", + "requires": {} + }, "iterator.prototype": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/iterator.prototype/-/iterator.prototype-1.1.2.tgz", @@ -17226,6 +17297,32 @@ "es-object-atoms": "^1.0.0" } }, + "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==", + "requires": { + "@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" + }, + "dependencies": { + "eventemitter3": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz", + "integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==" + }, + "type-fest": { + "version": "3.13.1", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-3.13.1.tgz", + "integrity": "sha512-tLq3bSNx+xSpwvAJnzrK0Ep5CLNWjvFTOp71URMaAEWBfRb9nnJiBoUe0tF8bI4ZFO3omgBR6NvnbzVUT3Ly4g==" + } + } + }, "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 6b6bf5f2..ef92f269 100644 --- a/package.json +++ b/package.json @@ -44,6 +44,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.68.4", "pdfjs-dist": "^4.5.136", diff --git a/src/components/slideControls.tsx b/src/components/slideControls.tsx index fed21cb8..668732e9 100644 --- a/src/components/slideControls.tsx +++ b/src/components/slideControls.tsx @@ -8,6 +8,8 @@ interface SlideControlsProps { prevSlide: () => 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-16 py-8 px-16 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 e12da342..9f506020 100644 --- a/src/components/slides.tsx +++ b/src/components/slides.tsx @@ -4,6 +4,13 @@ import homeStore from '@/features/stores/home' import { speakMessageHandler } from '@/features/chat/handlers' import SlideContent from './slideContent' import SlideControls from './slideControls' +import OBSWebSocket from 'obs-websocket-js' + +// OBS接続用の設定 +const obsConfig = { + address: '172.27.223.104:4455', // OBS WebSocketのデフォルトアドレス + password: 'testtest', // 必要に応じてパスワードを設定 +} interface SlidesProps { markdown: string @@ -22,6 +29,71 @@ const Slides: React.FC = ({ markdown }) => { const selectedSlideDocs = slideStore((state) => state.selectedSlideDocs) const chatProcessingCount = homeStore((s) => s.chatProcessingCount) const [slideCount, setSlideCount] = useState(0) + + // OBS接続関連の状態を追加 + const [obs] = useState(new OBSWebSocket()) + const [obsConnected, setObsConnected] = useState(false) + const [isRecording, setIsRecording] = useState(false) + + // OBSに接続する関数 + const connectToOBS = useCallback(async () => { + try { + await obs.connect(obsConfig.address, obsConfig.password) + console.log('OBS Studio に接続しました') + setObsConnected(true) + } catch (error) { + console.error('OBS Studioへの接続に失敗しました:', error) + setObsConnected(false) + } + }, [obs]) + + // 録画を開始する関数 + const startRecording = useCallback(async () => { + if (!obsConnected) return + + try { + // 録画中でない場合のみ録画開始 + const { outputActive } = await obs.call('GetRecordStatus') + if (!outputActive) { + await obs.call('StartRecord') + setIsRecording(true) + console.log('録画を開始しました') + } + } catch (error) { + console.error('録画開始に失敗しました:', error) + } + }, [obs, obsConnected]) + + // 録画を停止する関数 + const stopRecording = useCallback(async () => { + if (!obsConnected || !isRecording) return + + try { + await obs.call('StopRecord') + setIsRecording(false) + console.log('録画を停止しました') + } catch (error) { + console.error('録画停止に失敗しました:', error) + } + }, [obs, obsConnected, isRecording]) + + // コンポーネントマウント時にOBSに接続 + useEffect(() => { + connectToOBS() + + // コンポーネントアンマウント時に接続を切断 + return () => { + if (obsConnected) { + // 録画中なら停止 + if (isRecording) { + stopRecording() + } + // 接続を切断 + obs.disconnect() + console.log('OBS Studioとの接続を切断しました') + } + } + }, [connectToOBS, obs, obsConnected, isRecording, stopRecording]) useEffect(() => { const currentMarpitContainer = document.querySelector('.marpit') @@ -151,12 +223,28 @@ const Slides: React.FC = ({ markdown }) => { }) }, [isPlaying, readSlide, slideCount]) + // isPlayingの変更を監視して録画を制御 + useEffect(() => { + if (isPlaying && obsConnected && !isRecording) { + // スライドショー開始時に録画開始 + startRecording() + } else if (!isPlaying && obsConnected && isRecording) { + // スライドショー終了時に録画停止 + stopRecording() + } + }, [isPlaying, obsConnected, isRecording, startRecording, stopRecording]) + + // 最後のスライドに到達時の処理(既存のコード)を拡張 useEffect(() => { // 最後のスライドに達した場合、isPlayingをfalseに設定 if (currentSlide === slideCount - 1 && chatProcessingCount === 0) { slideStore.setState({ isPlaying: false }) + // 録画中なら停止 + if (obsConnected && isRecording) { + stopRecording() + } } - }, [currentSlide, slideCount, chatProcessingCount]) + }, [currentSlide, slideCount, chatProcessingCount, obsConnected, isRecording, stopRecording]) const prevSlide = useCallback(() => { slideStore.setState((state) => ({ @@ -217,6 +305,8 @@ const Slides: React.FC = ({ markdown }) => { prevSlide={prevSlide} nextSlide={nextSlide} toggleIsPlaying={toggleIsPlaying} + obsConnected={obsConnected} + isRecording={isRecording} />
From 623aacb7d8a20bb18c6d68447352b569ec5c968e Mon Sep 17 00:00:00 2001 From: tegnike Date: Mon, 24 Mar 2025 16:22:44 +0100 Subject: [PATCH 05/23] =?UTF-8?q?=E7=92=B0=E5=A2=83=E5=A4=89=E6=95=B0?= =?UTF-8?q?=E3=81=AE=E8=A8=AD=E5=AE=9A=E5=80=A4=E4=BF=AE=E6=AD=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.env.example b/.env.example index b0cb611e..df652489 100644 --- a/.env.example +++ b/.env.example @@ -43,11 +43,11 @@ NEXT_PUBLIC_MODEL_TYPE="vrm" NEXT_PUBLIC_SYSTEM_PROMPT="あなたはニケという名前のAIアシスタントです。親しみやすく、明るい性格で話してください。適宜次のような感情タグを使って表情や声のトーンを変えてください。[neutral] - 通常の表情、[happy] - 嬉しい表情、[sad] - 悲しい表情、[angry] - 怒りの表情、[relaxed] - リラックスした表情" # 選択する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/model3.json" # Live2D感情設定(カンマ区切りで複数指定可能) / # Live2D emotion settings (multiple can be specified with commas) From cb1c70270a98c7bc4ea4d9d3b26c5efc2a6f97a3 Mon Sep 17 00:00:00 2001 From: tegnike Date: Mon, 24 Mar 2025 16:34:54 +0100 Subject: [PATCH 06/23] =?UTF-8?q?=E7=92=B0=E5=A2=83=E5=A4=89=E6=95=B0?= =?UTF-8?q?=E3=81=AE=E8=A8=AD=E5=AE=9A=E5=80=A4=E4=BF=AE=E6=AD=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.env.example b/.env.example index df652489..f5b13368 100644 --- a/.env.example +++ b/.env.example @@ -47,7 +47,7 @@ NEXT_PUBLIC_SELECTED_VRM_PATH="/vrm/nikechan_v2.vrm" # 選択するLive2Dモデルのモデルファイルのパス / # Path to the selected Live2D model file -NEXT_PUBLIC_SELECTED_LIVE2D_PATH="/live2d/nike01/model3.json" +NEXT_PUBLIC_SELECTED_LIVE2D_PATH="/live2d/nike01/nike01.model3.json" # Live2D感情設定(カンマ区切りで複数指定可能) / # Live2D emotion settings (multiple can be specified with commas) From 0b734374d7ae7789c78e6548a144d63026e78fb3 Mon Sep 17 00:00:00 2001 From: Satoh Kiyoshi Date: Tue, 25 Mar 2025 19:06:44 +0900 Subject: [PATCH 07/23] =?UTF-8?q?obs=E3=82=92=E5=88=A9=E7=94=A8=E3=81=97?= =?UTF-8?q?=E3=81=A6=E8=87=AA=E5=8B=95=E3=81=A7=E9=8C=B2=E7=94=BB=E9=96=8B?= =?UTF-8?q?=E5=A7=8B/=E5=81=9C=E6=AD=A2=E3=82=92=E8=A1=8C=E3=81=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/slides.tsx | 77 +++++++++++++++++++++++++-------------- 1 file changed, 49 insertions(+), 28 deletions(-) diff --git a/src/components/slides.tsx b/src/components/slides.tsx index 9f506020..d0a08e7e 100644 --- a/src/components/slides.tsx +++ b/src/components/slides.tsx @@ -4,11 +4,13 @@ import homeStore from '@/features/stores/home' import { speakMessageHandler } from '@/features/chat/handlers' import SlideContent from './slideContent' import SlideControls from './slideControls' -import OBSWebSocket from 'obs-websocket-js' +import * as OBSWebSocketModule from 'obs-websocket-js' + +const OBSWebSocket = OBSWebSocketModule.default || OBSWebSocketModule; // OBS接続用の設定 const obsConfig = { - address: '172.27.223.104:4455', // OBS WebSocketのデフォルトアドレス + url: 'ws://localhost:4455', password: 'testtest', // 必要に応じてパスワードを設定 } @@ -31,66 +33,85 @@ const Slides: React.FC = ({ markdown }) => { const [slideCount, setSlideCount] = useState(0) // OBS接続関連の状態を追加 - const [obs] = useState(new OBSWebSocket()) - const [obsConnected, setObsConnected] = useState(false) - const [isRecording, setIsRecording] = useState(false) + 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 connectToOBS = useCallback(async () => { + if (!obs) { + console.error('OBSWebSocketインスタンスが作成されていません'); + return; + } + try { - await obs.connect(obsConfig.address, obsConfig.password) - console.log('OBS Studio に接続しました') - setObsConnected(true) + console.log('OBS Studioに接続を試みています...'); + await obs.connect(obsConfig.url, obsConfig.password); + console.log('OBS Studio に接続しました'); + setObsConnected(true); } catch (error) { - console.error('OBS Studioへの接続に失敗しました:', error) - setObsConnected(false) + console.error('OBS Studioへの接続に失敗しました:', error); + setObsConnected(false); } }, [obs]) // 録画を開始する関数 const startRecording = useCallback(async () => { - if (!obsConnected) return + if (!obs || !obsConnected) return; try { // 録画中でない場合のみ録画開始 - const { outputActive } = await obs.call('GetRecordStatus') + const { outputActive } = await obs.call('GetRecordStatus'); if (!outputActive) { - await obs.call('StartRecord') - setIsRecording(true) - console.log('録画を開始しました') + await obs.call('StartRecord'); + setIsRecording(true); + console.log('録画を開始しました'); } } catch (error) { - console.error('録画開始に失敗しました:', error) + console.error('録画開始に失敗しました:', error); } - }, [obs, obsConnected]) + }, [obs, obsConnected]); // 録画を停止する関数 const stopRecording = useCallback(async () => { - if (!obsConnected || !isRecording) return + if (!obs || !obsConnected || !isRecording) return; try { - await obs.call('StopRecord') - setIsRecording(false) - console.log('録画を停止しました') + await obs.call('StopRecord'); + setIsRecording(false); + console.log('録画を停止しました'); } catch (error) { - console.error('録画停止に失敗しました:', error) + console.error('録画停止に失敗しました:', error); } - }, [obs, obsConnected, isRecording]) + }, [obs, obsConnected, isRecording]); // コンポーネントマウント時にOBSに接続 useEffect(() => { - connectToOBS() + // obsインスタンスが作成されたら接続を試みる + if (obs) { + connectToOBS(); + } // コンポーネントアンマウント時に接続を切断 return () => { - if (obsConnected) { + if (obs && obsConnected) { // 録画中なら停止 if (isRecording) { - stopRecording() + stopRecording(); } // 接続を切断 - obs.disconnect() - console.log('OBS Studioとの接続を切断しました') + try { + obs.disconnect(); + console.log('OBS Studioとの接続を切断しました'); + } catch (error) { + console.error('OBS Studioとの接続切断に失敗しました:', error); + } } } }, [connectToOBS, obs, obsConnected, isRecording, stopRecording]) From 394e3341932132bf8ff2d214082a61169f4374de Mon Sep 17 00:00:00 2001 From: SATOH Kiyoshi Date: Tue, 25 Mar 2025 19:56:13 +0900 Subject: [PATCH 08/23] =?UTF-8?q?=E3=82=B9=E3=83=A9=E3=82=A4=E3=83=89?= =?UTF-8?q?=E3=82=92URL=E3=81=8B=E3=82=89=E6=8C=87=E5=AE=9A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/_app.tsx | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/src/pages/_app.tsx b/src/pages/_app.tsx index f1a8b175..fe3aa5a7 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,37 @@ 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({ selectedSlideDocs: slide }) + + // スライドを表示 + menuStore.setState({ slideVisible: true }) + + // 自動再生が指定された場合 + if (autoplay === 'true') { + console.log('スライドの自動再生を開始します') + // 少し遅延を入れてスライドの準備ができてから再生開始 + setTimeout(() => { + slideStore.setState({ isPlaying: true }) + }, 2000) + } + } + }, [router.isReady, router.query]) + return ( <> From 3ac4f89cf53d10801b4a379b75d129a9710a7166 Mon Sep 17 00:00:00 2001 From: Satoh Kiyoshi Date: Wed, 26 Mar 2025 20:01:08 +0900 Subject: [PATCH 09/23] =?UTF-8?q?slide=E3=81=AEautoplay=E3=81=AE=E3=81=A8?= =?UTF-8?q?=E3=81=8D=E3=81=AB=E3=81=AF=E3=82=A4=E3=83=B3=E3=83=88=E3=83=AD?= =?UTF-8?q?=E3=83=80=E3=82=AF=E3=82=B7=E3=83=A7=E3=83=B3=E9=9D=9E=E8=A1=A8?= =?UTF-8?q?=E7=A4=BA=E3=81=AB=E3=81=99=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/_app.tsx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/pages/_app.tsx b/src/pages/_app.tsx index fe3aa5a7..7f324776 100644 --- a/src/pages/_app.tsx +++ b/src/pages/_app.tsx @@ -65,6 +65,10 @@ export default function App({ Component, pageProps }: AppProps) { // 自動再生が指定された場合 if (autoplay === 'true') { console.log('スライドの自動再生を開始します') + + // イントロダクションを非表示にする + homeStore.setState({ showIntroduction: false }) + // 少し遅延を入れてスライドの準備ができてから再生開始 setTimeout(() => { slideStore.setState({ isPlaying: true }) From 379244af14d1c61319936eebc85c4dee7a7743d6 Mon Sep 17 00:00:00 2001 From: Satoh Kiyoshi Date: Wed, 26 Mar 2025 20:17:21 +0900 Subject: [PATCH 10/23] =?UTF-8?q?.env=E3=81=ABOBS=20Studio=E3=81=AE?= =?UTF-8?q?=E8=A8=AD=E5=AE=9A=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 4 ++++ README.md | 21 +++++++++++++++++++++ src/components/slides.tsx | 6 +++--- 3 files changed, 28 insertions(+), 3 deletions(-) diff --git a/.env.example b/.env.example index 5c1001a8..423085db 100644 --- a/.env.example +++ b/.env.example @@ -188,3 +188,7 @@ NEXT_PUBLIC_USE_VIDEO_AS_BACKGROUND=false # Temperature NEXT_PUBLIC_TEMPERATURE=1.0 + +# OBS Studio Settings +NEXT_PUBLIC_OBS_WEBSOCKET_URL=ws://localhost:4455 +NEXT_PUBLIC_OBS_WEBSOCKET_PASSWORD="" diff --git a/README.md b/README.md index 263c7385..c11c2f3e 100644 --- a/README.md +++ b/README.md @@ -370,3 +370,24 @@ Cubism 2.1とCubism 4/5を使用することで、すべてのバリアントの - [ロゴの利用規約](./docs/logo_licence.md) - [VRMおよびLive2Dモデルの利用規約](./docs/character_model_licence.md) + +## 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/src/components/slides.tsx b/src/components/slides.tsx index d0a08e7e..98e3003c 100644 --- a/src/components/slides.tsx +++ b/src/components/slides.tsx @@ -8,10 +8,10 @@ import * as OBSWebSocketModule from 'obs-websocket-js' const OBSWebSocket = OBSWebSocketModule.default || OBSWebSocketModule; -// OBS接続用の設定 +// OBS接続用の設定を環境変数から取得 const obsConfig = { - url: 'ws://localhost:4455', - password: 'testtest', // 必要に応じてパスワードを設定 + url: process.env.NEXT_PUBLIC_OBS_WEBSOCKET_URL || 'ws://localhost:4455', + password: process.env.NEXT_PUBLIC_OBS_WEBSOCKET_PASSWORD || '', // パスワードを.envから取得 } interface SlidesProps { From 48ad16df99ec182860a66636a6acaf9cab930a37 Mon Sep 17 00:00:00 2001 From: Satoh Kiyoshi Date: Wed, 26 Mar 2025 20:24:59 +0900 Subject: [PATCH 11/23] =?UTF-8?q?=E9=8C=B2=E7=94=BB=E7=8A=B6=E6=85=8B?= =?UTF-8?q?=E3=82=92OBS=E3=81=AEGetRecordStatus=E3=81=8B=E3=82=89=E5=8F=96?= =?UTF-8?q?=E5=BE=97=E3=81=99=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/slideControls.tsx | 6 ++- src/components/slides.tsx | 72 +++++++++++++++++++++++++++----- 2 files changed, 65 insertions(+), 13 deletions(-) diff --git a/src/components/slideControls.tsx b/src/components/slideControls.tsx index 668732e9..7bcf0744 100644 --- a/src/components/slideControls.tsx +++ b/src/components/slideControls.tsx @@ -51,12 +51,14 @@ const SlideControls: React.FC = ({ {obsConnected !== undefined && (
- OBS {obsConnected ? '接続中' : '未接続'} + OBS {obsConnected ? '接続中' : '未接続'} {obsConnected && isRecording !== undefined && (
- {isRecording ? '録画中' : '録画停止'} + + {isRecording ? '🔴 録画中' : '⚪ 録画停止'} +
)}
diff --git a/src/components/slides.tsx b/src/components/slides.tsx index 98e3003c..5b0f62d0 100644 --- a/src/components/slides.tsx +++ b/src/components/slides.tsx @@ -43,6 +43,23 @@ const Slides: React.FC = ({ markdown }) => { 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) { @@ -54,42 +71,75 @@ const Slides: React.FC = ({ markdown }) => { console.log('OBS Studioに接続を試みています...'); await obs.connect(obsConfig.url, obsConfig.password); console.log('OBS Studio に接続しました'); - setObsConnected(true); + setObsConnected(true); + + // 接続後すぐに録画状態を確認 + await checkRecordingStatus(); + + // OBSからのイベント通知を設定 + obs.on('RecordStateChanged', ({ outputActive }) => { + console.log(`OBSの録画状態が変更されました: ${outputActive ? '録画中' : '録画停止'}`); + setIsRecording(outputActive); + }); } catch (error) { console.error('OBS Studioへの接続に失敗しました:', error); setObsConnected(false); } - }, [obs]) + }, [obs, checkRecordingStatus]); // 録画を開始する関数 const startRecording = useCallback(async () => { if (!obs || !obsConnected) return; try { + // 録画状態を最新に更新 + await checkRecordingStatus(); + // 録画中でない場合のみ録画開始 - const { outputActive } = await obs.call('GetRecordStatus'); - if (!outputActive) { + if (!isRecording) { await obs.call('StartRecord'); setIsRecording(true); console.log('録画を開始しました'); + } else { + console.log('既に録画中です'); } } catch (error) { console.error('録画開始に失敗しました:', error); } - }, [obs, obsConnected]); + }, [obs, obsConnected, isRecording, checkRecordingStatus]); // 録画を停止する関数 const stopRecording = useCallback(async () => { - if (!obs || !obsConnected || !isRecording) return; + if (!obs || !obsConnected) return; try { - await obs.call('StopRecord'); - setIsRecording(false); - console.log('録画を停止しました'); + // 録画状態を最新に更新 + await checkRecordingStatus(); + + // 録画中の場合のみ録画停止 + if (isRecording) { + await obs.call('StopRecord'); + setIsRecording(false); + console.log('録画を停止しました'); + } else { + console.log('録画が停止されていません'); + } } catch (error) { console.error('録画停止に失敗しました:', error); } - }, [obs, obsConnected, isRecording]); + }, [obs, obsConnected, isRecording, checkRecordingStatus]); + + // 定期的に録画状態を確認 + useEffect(() => { + if (!obsConnected) return; + + // 5秒ごとに録画状態を確認 + const interval = setInterval(() => { + checkRecordingStatus(); + }, 5000); + + return () => clearInterval(interval); + }, [obsConnected, checkRecordingStatus]); // コンポーネントマウント時にOBSに接続 useEffect(() => { @@ -114,7 +164,7 @@ const Slides: React.FC = ({ markdown }) => { } } } - }, [connectToOBS, obs, obsConnected, isRecording, stopRecording]) + }, [connectToOBS, obs, obsConnected, isRecording, stopRecording]); useEffect(() => { const currentMarpitContainer = document.querySelector('.marpit') From 50d0e0e428409cd79bf9fa17f3e0fd4d4ef73224 Mon Sep 17 00:00:00 2001 From: Satoh Kiyoshi Date: Wed, 26 Mar 2025 20:30:12 +0900 Subject: [PATCH 12/23] =?UTF-8?q?autoplay=E3=83=A2=E3=83=BC=E3=83=89?= =?UTF-8?q?=E3=81=AE=E3=81=A8=E3=81=8D=E3=81=AB=E3=81=AF=E4=B8=8A=E9=83=A8?= =?UTF-8?q?=E3=83=A1=E3=83=8B=E3=83=A5=E3=83=BC=E3=82=84=E3=82=B9=E3=83=A9?= =?UTF-8?q?=E3=82=A4=E3=83=89=E3=81=AE=E3=82=B3=E3=83=B3=E3=83=88=E3=83=AD?= =?UTF-8?q?=E3=83=BC=E3=83=AB=E3=83=9C=E3=82=BF=E3=83=B3=E3=82=92=E8=A1=A8?= =?UTF-8?q?=E7=A4=BA=E3=81=97=E3=81=AA=E3=81=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/menu.tsx | 243 ++++++++++++++++++----------------- src/components/slides.tsx | 56 ++++---- src/features/stores/slide.ts | 2 + src/pages/_app.tsx | 6 + 4 files changed, 164 insertions(+), 143 deletions(-) diff --git a/src/components/menu.tsx b/src/components/menu.tsx index 9039f68a..824cb745 100644 --- a/src/components/menu.tsx +++ b/src/components/menu.tsx @@ -28,6 +28,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) @@ -138,132 +139,138 @@ export const Menu = () => { return ( <> -
-
- {showControlPanel && ( - <> -
- setShowSettings(true)} - > -
-
- {showChatLog ? ( - setShowChatLog(false)} - /> - ) : ( + {!isAutoplay && ( +
+
+ {showControlPanel && ( + <> +
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, - }) - } - /> + 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 && ( +
+
+

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

+ +
+
+ )} + )} = ({ markdown }) => { await checkRecordingStatus(); // OBSからのイベント通知を設定 - obs.on('RecordStateChanged', ({ outputActive }) => { - console.log(`OBSの録画状態が変更されました: ${outputActive ? '録画中' : '録画停止'}`); - setIsRecording(outputActive); + obs.on('RecordStateChanged', (event: { outputActive: boolean }) => { + console.log(`OBSの録画状態が変更されました: ${event.outputActive ? '録画中' : '録画停止'}`); + setIsRecording(event.outputActive); }); } catch (error) { console.error('OBS Studioへの接続に失敗しました:', error); @@ -343,6 +343,9 @@ const Slides: React.FC = ({ markdown }) => { } }, [chatProcessingCount, isPlaying, nextSlide, currentSlide, slideCount]) + // 自動再生モードかどうかを取得 + const isAutoplay = slideStore((state) => state.isAutoplay); + return ( <>
= ({ markdown }) => { >
-
- -
+ {/* 自動再生モードでない場合のみコントロールを表示 */} + {!isAutoplay && ( +
+ +
+ )} ) } 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 7f324776..c12cb1ce 100644 --- a/src/pages/_app.tsx +++ b/src/pages/_app.tsx @@ -69,10 +69,16 @@ export default function App({ Component, pageProps }: AppProps) { // イントロダクションを非表示にする homeStore.setState({ showIntroduction: false }) + // 自動再生モードを設定 + slideStore.setState({ isAutoplay: true }) + // 少し遅延を入れてスライドの準備ができてから再生開始 setTimeout(() => { slideStore.setState({ isPlaying: true }) }, 2000) + } else { + // 自動再生ではない場合、明示的にフラグをオフに + slideStore.setState({ isAutoplay: false }) } } }, [router.isReady, router.query]) From 0d27e3b5a1d5a66b281b32b52105d6cd793101d5 Mon Sep 17 00:00:00 2001 From: Satoh Kiyoshi Date: Wed, 26 Mar 2025 21:00:01 +0900 Subject: [PATCH 13/23] =?UTF-8?q?=E3=82=B9=E3=83=A9=E3=82=A4=E3=83=89?= =?UTF-8?q?=E4=B8=AD=E3=81=AE=E5=8B=95=E7=94=BB=E3=81=8C=E5=85=A8=E9=83=A8?= =?UTF-8?q?=E5=86=8D=E7=94=9F=E3=81=97=E3=81=9F=E3=82=89=E6=AC=A1=E3=81=AE?= =?UTF-8?q?=E3=82=B9=E3=83=A9=E3=82=A4=E3=83=89=E3=81=B8=E7=A7=BB=E5=8B=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/slides.tsx | 77 ++++++++++++++++++++++++++++++++------- 1 file changed, 64 insertions(+), 13 deletions(-) diff --git a/src/components/slides.tsx b/src/components/slides.tsx index 7b97f3fb..7d19f0fd 100644 --- a/src/components/slides.tsx +++ b/src/components/slides.tsx @@ -1,4 +1,4 @@ -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' @@ -32,6 +32,11 @@ const Slides: React.FC = ({ markdown }) => { const chatProcessingCount = homeStore((s) => s.chatProcessingCount) const [slideCount, setSlideCount] = useState(0) + // 動画の再生状態を追跡 + const [videoPlaying, setVideoPlaying] = useState(false) + // 現在のスライドの動画要素を追跡するref + const currentVideosRef = useRef([]) + // OBS接続関連の状態を追加 const [obs, setObs] = useState(null); const [obsConnected, setObsConnected] = useState(false); @@ -166,6 +171,19 @@ const Slides: React.FC = ({ markdown }) => { } }, [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 + ); + }, []); + + // 現在表示しているスライドの動画要素を追跡 useEffect(() => { const currentMarpitContainer = document.querySelector('.marpit') if (!currentMarpitContainer) return @@ -177,14 +195,40 @@ const Slides: React.FC = ({ markdown }) => { slide.removeAttribute('hidden') slide.setAttribute('style', 'display: block;') - // 新しく表示されるスライド内の video を再生 + // 新しく表示されるスライド内の video を再生し、イベントリスナーを追加 const videos = slide.querySelectorAll('video') as NodeListOf + const videoArray: HTMLVideoElement[] = []; + + if (videos.length > 0) { + console.log(`スライド${currentSlide}には${videos.length}個の動画があります`); + setVideoPlaying(true); + } else { + setVideoPlaying(false); + } + videos.forEach((video) => { - //video.muted = true + // ビデオの再生が終了したときのイベントリスナーを設定 + 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', '') @@ -199,7 +243,7 @@ const Slides: React.FC = ({ markdown }) => { }) } }) - }, [currentSlide, marpitContainer]) + }, [currentSlide, marpitContainer, checkAllVideosEnded]) useEffect(() => { const convertMarkdown = async () => { @@ -333,15 +377,22 @@ const Slides: React.FC = ({ markdown }) => { } } + // 音声ナレーションが終了し、動画も終了した場合に次のスライドへ進む useEffect(() => { - if ( - chatProcessingCount === 0 && - isPlaying && - currentSlide < slideCount - 1 - ) { - nextSlide() + if (isPlaying && currentSlide < slideCount - 1) { + if (chatProcessingCount === 0 && !videoPlaying) { + console.log('音声ナレーションと動画の両方が終了したため、次のスライドへ進みます'); + nextSlide(); + } else { + if (chatProcessingCount > 0) { + console.log('音声ナレーション再生中...'); + } + if (videoPlaying) { + console.log('動画再生中...'); + } + } } - }, [chatProcessingCount, isPlaying, nextSlide, currentSlide, slideCount]) + }, [chatProcessingCount, videoPlaying, isPlaying, nextSlide, currentSlide, slideCount]); // 自動再生モードかどうかを取得 const isAutoplay = slideStore((state) => state.isAutoplay); From 7d6c27d5272d5e7ce4375a95258b66038e95a907 Mon Sep 17 00:00:00 2001 From: Satoh Kiyoshi Date: Wed, 26 Mar 2025 21:38:27 +0900 Subject: [PATCH 14/23] =?UTF-8?q?=E6=9C=80=E5=88=9D=E3=81=AE=E3=83=9A?= =?UTF-8?q?=E3=83=BC=E3=82=B8=E3=81=8C=E5=86=8D=E7=94=9F=E3=81=95=E3=82=8C?= =?UTF-8?q?=E3=81=AA=E3=81=84=E5=95=8F=E9=A1=8C=E3=81=AE=E5=AF=BE=E5=BF=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/slides.tsx | 64 +++++++++++++++++++++++++++------------ src/pages/_app.tsx | 17 +++++++---- 2 files changed, 56 insertions(+), 25 deletions(-) diff --git a/src/components/slides.tsx b/src/components/slides.tsx index 7d19f0fd..bc372a4b 100644 --- a/src/components/slides.tsx +++ b/src/components/slides.tsx @@ -183,6 +183,31 @@ const Slides: React.FC = ({ markdown }) => { ); }, []); + // スライドの音声を読み上げる関数を先に定義 + 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(currentLines) + speakMessageHandler(currentLines) + }, + [selectedSlideDocs] + ) + // 現在表示しているスライドの動画要素を追跡 useEffect(() => { const currentMarpitContainer = document.querySelector('.marpit') @@ -277,6 +302,25 @@ const Slides: React.FC = ({ markdown }) => { slide.setAttribute('style', 'display: none;') } }) + + // スライドのロードが完了したタイミングで、自動再生モードの場合は処理を開始 + const isAutoplayMode = slideStore.getState().isAutoplay; + if (isAutoplayMode && !slideStore.getState().isPlaying) { + console.log(`スライド「${selectedSlideDocs}」のロードが完了しました。自動再生を開始します。`); + + // 現在のスライドを明示的に0に設定 + slideStore.setState({ currentSlide: 0 }); + + // 十分な遅延を入れてから再生開始 + // これにより0ページ目が確実に表示された状態で音声が始まる + setTimeout(() => { + console.log(`スライド${0}の音声を読み上げ開始します`); + // 最初のスライドの音声を読み上げ + readSlide(0); + // 再生状態を設定 + slideStore.setState({ isPlaying: true }); + }, 2000); + } } // CSSを動的に適用 @@ -289,6 +333,7 @@ const Slides: React.FC = ({ markdown }) => { } } + console.log(`スライド「${selectedSlideDocs}」の読み込みを開始します...`); convertMarkdown() }, [selectedSlideDocs]) @@ -309,25 +354,6 @@ const Slides: React.FC = ({ markdown }) => { } }, []) - 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 : '' - } - - const currentLines = getCurrentLines() - console.log(currentLines) - speakMessageHandler(currentLines) - }, - [selectedSlideDocs] - ) - const nextSlide = useCallback(() => { slideStore.setState((state) => { const newSlide = Math.min(state.currentSlide + 1, slideCount - 1) diff --git a/src/pages/_app.tsx b/src/pages/_app.tsx index c12cb1ce..48cb4bdd 100644 --- a/src/pages/_app.tsx +++ b/src/pages/_app.tsx @@ -56,7 +56,14 @@ export default function App({ Component, pageProps }: AppProps) { // スライドモードを有効化 settingsStore.setState({ slideMode: true }) - // スライドを選択 + // 強制的に初期状態にリセット + slideStore.setState({ + isPlaying: false, + currentSlide: 0, + isAutoplay: false + }); + + // スライドを選択(この時点ではまだロード中) slideStore.setState({ selectedSlideDocs: slide }) // スライドを表示 @@ -64,7 +71,7 @@ export default function App({ Component, pageProps }: AppProps) { // 自動再生が指定された場合 if (autoplay === 'true') { - console.log('スライドの自動再生を開始します') + console.log('スライドの自動再生モードを設定します') // イントロダクションを非表示にする homeStore.setState({ showIntroduction: false }) @@ -72,10 +79,8 @@ export default function App({ Component, pageProps }: AppProps) { // 自動再生モードを設定 slideStore.setState({ isAutoplay: true }) - // 少し遅延を入れてスライドの準備ができてから再生開始 - setTimeout(() => { - slideStore.setState({ isPlaying: true }) - }, 2000) + // 再生自体はslides.tsxのマークダウン変換完了後のタイミングで行う + // そのため、ここではisPlayingの設定は行わない } else { // 自動再生ではない場合、明示的にフラグをオフに slideStore.setState({ isAutoplay: false }) From 984023868eeeb53038aaf1a91701c3a062800d47 Mon Sep 17 00:00:00 2001 From: Satoh Kiyoshi Date: Wed, 26 Mar 2025 21:47:37 +0900 Subject: [PATCH 15/23] =?UTF-8?q?=E3=82=B9=E3=83=A9=E3=82=A4=E3=83=89?= =?UTF-8?q?=E3=81=AE=E7=94=9F=E6=88=90=E3=81=8C=E5=AE=8C=E4=BA=86=E3=81=97?= =?UTF-8?q?=E3=81=9F=E3=82=89=E5=86=8D=E7=94=9F=E3=82=92=E8=A1=8C=E3=81=86?= =?UTF-8?q?=E3=82=88=E3=81=86=E3=81=AB=E4=BF=AE=E6=AD=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/slides.tsx | 253 ++++++++++++++++++++++---------------- 1 file changed, 145 insertions(+), 108 deletions(-) diff --git a/src/components/slides.tsx b/src/components/slides.tsx index bc372a4b..8fca2e76 100644 --- a/src/components/slides.tsx +++ b/src/components/slides.tsx @@ -29,9 +29,13 @@ 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 @@ -183,7 +187,7 @@ const Slides: React.FC = ({ markdown }) => { ); }, []); - // スライドの音声を読み上げる関数を先に定義 + // スライドの音声を読み上げる関数 const readSlide = useCallback( (slideIndex: number) => { const getCurrentLines = () => { @@ -202,7 +206,7 @@ const Slides: React.FC = ({ markdown }) => { } const currentLines = getCurrentLines() - console.log(currentLines) + console.log(`スライド${slideIndex}を読み上げ: ${currentLines}`) speakMessageHandler(currentLines) }, [selectedSlideDocs] @@ -270,158 +274,191 @@ const Slides: React.FC = ({ markdown }) => { }) }, [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.setAttribute('style', 'display: block;') + slide.removeAttribute('hidden'); + slide.setAttribute('style', 'display: block;'); } else { - slide.setAttribute('hidden', '') - slide.setAttribute('style', 'display: none;') + slide.setAttribute('hidden', ''); + slide.setAttribute('style', 'display: none;'); } - }) - - // スライドのロードが完了したタイミングで、自動再生モードの場合は処理を開始 - const isAutoplayMode = slideStore.getState().isAutoplay; - if (isAutoplayMode && !slideStore.getState().isPlaying) { - console.log(`スライド「${selectedSlideDocs}」のロードが完了しました。自動再生を開始します。`); - - // 現在のスライドを明示的に0に設定 - slideStore.setState({ currentSlide: 0 }); - - // 十分な遅延を入れてから再生開始 - // これにより0ページ目が確実に表示された状態で音声が始まる - setTimeout(() => { - console.log(`スライド${0}の音声を読み上げ開始します`); - // 最初のスライドの音声を読み上げ - readSlide(0); - // 再生状態を設定 - slideStore.setState({ isPlaying: true }); - }, 2000); - } - } + }); - // 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); } - } + }; - console.log(`スライド「${selectedSlideDocs}」の読み込みを開始します...`); - 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) { + console.log('スライドの準備が完了し、自動再生モードが有効なため、再生を開始します'); + + // 現在のスライドを明示的に0に設定 + slideStore.setState({ currentSlide: 0 }); + + // 少し遅延を入れてから再生開始(スライドが確実に表示されてから) + setTimeout(() => { + // 再生開始 + console.log('スライド0の音声を読み上げ、再生を開始します'); + readSlide(0); + slideStore.setState({ isPlaying: true }); + }, 1000); } - }, []) + }, [slidesReady, isAutoplay, isPlaying, readSlide]); + // 次のスライドに進む関数 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]) + + return { currentSlide: newSlide }; + }); + }, [isPlaying, readSlide, slideCount]); + + // 前のスライドに戻る関数 + const prevSlide = useCallback(() => { + slideStore.setState((state) => ({ + currentSlide: Math.max(state.currentSlide - 1, 0), + })); + }, []); + + // 再生/停止を切り替える関数 + const toggleIsPlaying = useCallback(() => { + const newIsPlaying = !isPlaying; + + // 状態を更新 + slideStore.setState({ isPlaying: newIsPlaying }); + + // 再生開始時には現在のスライドの音声を読み上げる + if (newIsPlaying) { + console.log(`手動再生: スライド${currentSlide}の音声を読み上げます`); + readSlide(currentSlide); + } + }, [isPlaying, currentSlide, readSlide]); // isPlayingの変更を監視して録画を制御 useEffect(() => { if (isPlaying && obsConnected && !isRecording) { // スライドショー開始時に録画開始 - startRecording() + startRecording(); } else if (!isPlaying && obsConnected && isRecording) { // スライドショー終了時に録画停止 - stopRecording() + stopRecording(); } - }, [isPlaying, obsConnected, isRecording, startRecording, stopRecording]) + }, [isPlaying, obsConnected, isRecording, startRecording, stopRecording]); - // 最後のスライドに到達時の処理(既存のコード)を拡張 + // 最後のスライドに到達時の処理 useEffect(() => { - // 最後のスライドに達した場合、isPlayingをfalseに設定 - if (currentSlide === slideCount - 1 && chatProcessingCount === 0) { - slideStore.setState({ isPlaying: false }) + // 最後のスライドに達し、かつ音声が終了した場合にisPlayingをfalseに設定 + if (currentSlide === slideCount - 1 && chatProcessingCount === 0 && !videoPlaying) { + slideStore.setState({ isPlaying: false }); + // 録画中なら停止 if (obsConnected && isRecording) { - stopRecording() + stopRecording(); } } - }, [currentSlide, slideCount, chatProcessingCount, obsConnected, isRecording, stopRecording]) + }, [currentSlide, slideCount, chatProcessingCount, videoPlaying, obsConnected, isRecording, stopRecording]); - const prevSlide = useCallback(() => { - slideStore.setState((state) => ({ - currentSlide: Math.max(state.currentSlide - 1, 0), - })) - }, []) - - const toggleIsPlaying = () => { - const newIsPlaying = !isPlaying - slideStore.setState({ - isPlaying: newIsPlaying, - }) - if (newIsPlaying) { - readSlide(currentSlide) - } - } - - // 音声ナレーションが終了し、動画も終了した場合に次のスライドへ進む + // 音声ナレーションと動画が終了したら次のスライドへ進む useEffect(() => { - if (isPlaying && currentSlide < slideCount - 1) { - if (chatProcessingCount === 0 && !videoPlaying) { - console.log('音声ナレーションと動画の両方が終了したため、次のスライドへ進みます'); - nextSlide(); - } else { - if (chatProcessingCount > 0) { - console.log('音声ナレーション再生中...'); - } - if (videoPlaying) { - console.log('動画再生中...'); + // 再生中で、最後のスライドではなく、音声と動画の両方が終了した場合 + 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, videoPlaying, isPlaying, nextSlide, currentSlide, slideCount]); - - // 自動再生モードかどうかを取得 - const isAutoplay = slideStore((state) => state.isAutoplay); + }, [chatProcessingCount, videoPlaying, isPlaying, currentSlide, slideCount, nextSlide]); return ( <> From 4c99353cd247baadff8715db63e62956a2da9146 Mon Sep 17 00:00:00 2001 From: Satoh Kiyoshi Date: Mon, 31 Mar 2025 20:47:57 +0900 Subject: [PATCH 16/23] =?UTF-8?q?=E3=82=B9=E3=83=A9=E3=82=A4=E3=83=89?= =?UTF-8?q?=E3=81=8C=E5=81=9C=E6=AD=A2=E3=81=97=E3=81=AA=E3=81=8B=E3=81=A3?= =?UTF-8?q?=E3=81=9F=E3=81=AE=E3=82=92=E4=BF=AE=E6=AD=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/slides.tsx | 71 +++++++++++++++++++++++++++++---------- 1 file changed, 53 insertions(+), 18 deletions(-) diff --git a/src/components/slides.tsx b/src/components/slides.tsx index 8fca2e76..62e61761 100644 --- a/src/components/slides.tsx +++ b/src/components/slides.tsx @@ -40,6 +40,8 @@ const Slides: React.FC = ({ markdown }) => { const [videoPlaying, setVideoPlaying] = useState(false) // 現在のスライドの動画要素を追跡するref const currentVideosRef = useRef([]) + // 自動再生の開始状態を追跡するためのRef + const playbackStartedRef = useRef(false); // OBS接続関連の状態を追加 const [obs, setObs] = useState(null); @@ -365,22 +367,68 @@ const Slides: React.FC = ({ markdown }) => { // スライドが準備完了したときに、自動再生モードであれば再生を開始 useEffect(() => { - if (slidesReady && isAutoplay && !isPlaying) { + // すでに再生を開始している場合は何もしない + if (slidesReady && isAutoplay && !isPlaying && !playbackStartedRef.current) { console.log('スライドの準備が完了し、自動再生モードが有効なため、再生を開始します'); + // 再生開始フラグを設定 + playbackStartedRef.current = true; // 現在のスライドを明示的に0に設定 slideStore.setState({ currentSlide: 0 }); // 少し遅延を入れてから再生開始(スライドが確実に表示されてから) setTimeout(() => { - // 再生開始 - console.log('スライド0の音声を読み上げ、再生を開始します'); - readSlide(0); - slideStore.setState({ isPlaying: true }); + // 状態が変わっていないことを確認 + if (isAutoplay && !slideStore.getState().isPlaying) { + console.log('スライド0の音声を読み上げ、再生を開始します'); + readSlide(0); + slideStore.setState({ isPlaying: true }); + } }, 1000); } }, [slidesReady, isAutoplay, isPlaying, readSlide]); + // 最後のスライドに到達時の処理を強化 + 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 nextSlide = useCallback(() => { slideStore.setState((state) => { @@ -427,19 +475,6 @@ const Slides: React.FC = ({ markdown }) => { } }, [isPlaying, obsConnected, isRecording, startRecording, stopRecording]); - // 最後のスライドに到達時の処理 - useEffect(() => { - // 最後のスライドに達し、かつ音声が終了した場合にisPlayingをfalseに設定 - if (currentSlide === slideCount - 1 && chatProcessingCount === 0 && !videoPlaying) { - slideStore.setState({ isPlaying: false }); - - // 録画中なら停止 - if (obsConnected && isRecording) { - stopRecording(); - } - } - }, [currentSlide, slideCount, chatProcessingCount, videoPlaying, obsConnected, isRecording, stopRecording]); - // 音声ナレーションと動画が終了したら次のスライドへ進む useEffect(() => { // 再生中で、最後のスライドではなく、音声と動画の両方が終了した場合 From b9f3fb4790babce0a7378c5e5f903c7d377128ab Mon Sep 17 00:00:00 2001 From: SATOH Kiyoshi Date: Mon, 31 Mar 2025 21:46:22 +0900 Subject: [PATCH 17/23] Create obs_recorder.md --- docs/obs_recorder.md | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 docs/obs_recorder.md 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 +``` From 7a84b72bc21a53d2a3ecbafaad4b606b87d848b2 Mon Sep 17 00:00:00 2001 From: SATOH Kiyoshi Date: Tue, 1 Apr 2025 21:21:18 +0900 Subject: [PATCH 18/23] =?UTF-8?q?=E3=83=87=E3=83=A2=E7=94=A8=E3=81=AEdocke?= =?UTF-8?q?r-compose=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docker-compose.override.yml | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 docker-compose.override.yml diff --git a/docker-compose.override.yml b/docker-compose.override.yml new file mode 100644 index 00000000..5a667008 --- /dev/null +++ b/docker-compose.override.yml @@ -0,0 +1,24 @@ +services: + obs: + image: obs-url-recorder + 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 From 286e36869439847ed0895c92ce2bbb3db9ac987a Mon Sep 17 00:00:00 2001 From: Satoh Kiyoshi Date: Mon, 7 Apr 2025 00:15:48 +0900 Subject: [PATCH 19/23] =?UTF-8?q?=E3=83=9E=E3=83=BC=E3=82=B8=E3=81=AE?= =?UTF-8?q?=E4=BF=AE=E6=AD=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/menu.tsx | 25 +++++++++---------------- 1 file changed, 9 insertions(+), 16 deletions(-) diff --git a/src/components/menu.tsx b/src/components/menu.tsx index 1f400a52..e7618b11 100644 --- a/src/components/menu.tsx +++ b/src/components/menu.tsx @@ -198,22 +198,15 @@ export const Menu = () => {
)} -
-
- {showControlPanel && ( - <> -
- setShowSettings(true)} - > -
-
- {showChatLog ? ( + {!isAutoplay && ( +
+
+ {showControlPanel && ( + <> +
Date: Mon, 7 Apr 2025 00:18:20 +0900 Subject: [PATCH 20/23] =?UTF-8?q?obs-websocket-js=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package-lock.json | 61 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 61 insertions(+) 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", From d2b1d2445f73fb7aec6a30c6c00f51165465bca9 Mon Sep 17 00:00:00 2001 From: SATOH Kiyoshi Date: Mon, 7 Apr 2025 00:44:10 +0900 Subject: [PATCH 21/23] =?UTF-8?q?obs-url-recorder=E7=94=A8=E3=81=AEdocker?= =?UTF-8?q?=E8=A8=AD=E5=AE=9A=E3=83=95=E3=82=A1=E3=82=A4=E3=83=AB=E8=BF=BD?= =?UTF-8?q?=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...e.override.yml => docker-compose.obs-url-recorder.override.yml | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename docker-compose.override.yml => docker-compose.obs-url-recorder.override.yml (100%) diff --git a/docker-compose.override.yml b/docker-compose.obs-url-recorder.override.yml similarity index 100% rename from docker-compose.override.yml rename to docker-compose.obs-url-recorder.override.yml From 2e8e9a9dbcadbb950d74f8d116d7d1f20ab48244 Mon Sep 17 00:00:00 2001 From: SATOH Kiyoshi Date: Mon, 7 Apr 2025 00:47:02 +0900 Subject: [PATCH 22/23] =?UTF-8?q?ghcr=E3=81=8B=E3=82=89obs-url-recorder?= =?UTF-8?q?=E3=81=AEdocker=E5=8F=96=E5=BE=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docker-compose.obs-url-recorder.override.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker-compose.obs-url-recorder.override.yml b/docker-compose.obs-url-recorder.override.yml index 5a667008..c33c0da3 100644 --- a/docker-compose.obs-url-recorder.override.yml +++ b/docker-compose.obs-url-recorder.override.yml @@ -1,6 +1,6 @@ services: obs: - image: obs-url-recorder + image: ghcr.io/stealthinu/obs-url-recorder:master environment: OBS_BROWSER_URL: http://app:3000/?slide=demo&autoplay=true ports: From 0250b7ecdd37289a5e032bb2a9f341040800d476 Mon Sep 17 00:00:00 2001 From: SATOH Kiyoshi Date: Mon, 7 Apr 2025 01:05:47 +0900 Subject: [PATCH 23/23] =?UTF-8?q?OBS=E7=94=A8=E3=81=AE=E3=83=87=E3=83=95?= =?UTF-8?q?=E3=82=A9=E3=83=AB=E3=83=88=E8=A8=AD=E5=AE=9A=E3=82=92docker=20?= =?UTF-8?q?compose=E3=81=AB=E5=90=88=E3=82=8F=E3=81=9B=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.env.example b/.env.example index 4ff11786..af80e2f2 100644 --- a/.env.example +++ b/.env.example @@ -357,8 +357,8 @@ NEXT_PUBLIC_YOUTUBE_LIVE_ID="" NEXT_PUBLIC_SLIDE_MODE="false" # OBS Studio Settings -NEXT_PUBLIC_OBS_WEBSOCKET_URL=ws://localhost:4455 -NEXT_PUBLIC_OBS_WEBSOCKET_PASSWORD="" +NEXT_PUBLIC_OBS_WEBSOCKET_URL=ws://obs:4455 +NEXT_PUBLIC_OBS_WEBSOCKET_PASSWORD="obswebsocket" #=============================================================================== # その他の設定 / Other Settings