agello 0.4.3 → 0.4.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +32 -1
- package/package.json +1 -1
- package/src/page-audio.ts +159 -0
- package/src/player.ts +41 -4
- package/src/screencast.ts +68 -2
- package/src/server.ts +44 -7
- package/src/tts.ts +289 -0
- package/web/agent-bridge.js +235 -10
- package/web/presentation-media.js +459 -0
package/web/agent-bridge.js
CHANGED
|
@@ -56,6 +56,8 @@
|
|
|
56
56
|
// el.clearHistory() clear saved chat for the current pane
|
|
57
57
|
// el.stopPresent() end presentation mode, tell the agent -> Promise<{ok, error?, notified?}>
|
|
58
58
|
|
|
59
|
+
import { PresentationMedia, DEFAULT_VOICE } from "./presentation-media.js";
|
|
60
|
+
|
|
59
61
|
const SCRIPT_ORIGIN = new URL(import.meta.url).origin;
|
|
60
62
|
|
|
61
63
|
let mdPromise;
|
|
@@ -81,6 +83,28 @@ const SCREEN_REASONS = {
|
|
|
81
83
|
tab_unknown: "에이전트의 herdr 탭을 확인할 수 없음",
|
|
82
84
|
no_active_tab: "브라우저에 활성 탭 없음",
|
|
83
85
|
};
|
|
86
|
+
const MEDIA_ERRORS = {
|
|
87
|
+
tts_key_required: "Typecast API 키를 적용한 뒤 TTS를 켜 주세요.",
|
|
88
|
+
tts_invalid_key: "Typecast API 키가 올바르지 않아요. 키를 확인한 뒤 다시 재생해 주세요.",
|
|
89
|
+
tts_local_key_unavailable: "로컬 키를 찾지 못했어요. ../yt-outlier/.env의 TYPECAST_API_KEY를 확인하거나 키를 직접 입력해 주세요.",
|
|
90
|
+
tts_driver_disconnected: "음성을 재생하던 화면의 연결이 끊겼어요. 이 화면에서 TTS를 켠 뒤 다시 재생해 주세요.",
|
|
91
|
+
tts_prepare_timeout: "음성 생성 시간이 초과됐어요. 연결을 확인하고 다시 재생해 주세요.",
|
|
92
|
+
tts_start_timeout: "음성이 시작되지 않았어요. TTS 켜기로 음성 재생을 허용한 뒤 다시 재생해 주세요.",
|
|
93
|
+
tts_end_timeout: "음성 완료를 확인하지 못했어요. 연결을 확인하고 다시 재생해 주세요.",
|
|
94
|
+
tts_playback_error: "음성을 재생하지 못했어요. TTS 켜기로 음성 재생을 허용한 뒤 다시 재생해 주세요.",
|
|
95
|
+
tts_provider_error: "Typecast 요청이 실패했어요. 키·목소리·사용 한도를 확인하고 다시 재생해 주세요.",
|
|
96
|
+
tts_network_error: "Typecast에 연결하지 못했어요. 인터넷 연결을 확인하고 다시 재생해 주세요.",
|
|
97
|
+
tts_text_limit: "대본 한 줄이 너무 길어요. 2,000자 이하로 나눠 주세요.",
|
|
98
|
+
tts_invalid_audio: "올바른 음성을 받지 못했어요. 목소리 설정을 확인하고 다시 재생해 주세요.",
|
|
99
|
+
tts_audio_not_found: "준비된 음성이 만료됐어요. 대본을 다시 재생해 주세요.",
|
|
100
|
+
tts_failed: "TTS 처리에 실패했어요. 설정을 확인하고 다시 재생하거나 TTS를 꺼 주세요.",
|
|
101
|
+
tts_cancelled: "음성 생성이 취소됐어요. 계속하려면 대본을 다시 재생해 주세요.",
|
|
102
|
+
tts_config_superseded: "TTS 설정이 변경됐어요. 현재 설정을 확인하고 다시 적용해 주세요.",
|
|
103
|
+
tts_stopped: "음성 재생이 종료됐어요. 계속하려면 TTS를 켠 뒤 대본을 재생해 주세요.",
|
|
104
|
+
invalid_voice: "목소리 ID가 올바르지 않아요. Typecast 목소리 ID를 확인해 주세요.",
|
|
105
|
+
invalid_viewer: "발표 화면 연결을 확인하지 못했어요. 페이지를 새로 고친 뒤 다시 시도해 주세요.",
|
|
106
|
+
invalid_tts_config: "TTS 설정이 올바르지 않아요. 키와 목소리 ID를 확인해 주세요.",
|
|
107
|
+
};
|
|
84
108
|
const ACTIONS = {
|
|
85
109
|
message: "메시지", approve: "승인", reject: "거절",
|
|
86
110
|
"present-stop": "발표 종료", "hand-raise": "손들기", "hand-lower": "손 내림", "hand-done": "질문 완료", annotate: "주석",
|
|
@@ -175,6 +199,21 @@ h1 { font-size: 14px; margin: 0; font-weight: 600; }
|
|
|
175
199
|
.screen-bar { display: flex; align-items: center; gap: 8px; padding: 8px 12px; font-size: 12px; color: var(--_muted);
|
|
176
200
|
border-bottom: 1px solid var(--_line); min-width: 0; }
|
|
177
201
|
.screen-bar .url { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1; }
|
|
202
|
+
.screen { position: relative; }
|
|
203
|
+
.media-controls { padding: 7px 12px; border-bottom: 1px solid var(--_line); font-size: 12px; background: var(--_panel); z-index: 6; }
|
|
204
|
+
.media-toolbar { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
|
|
205
|
+
.media-toolbar button { padding: 3px 9px; border-radius: 7px; font-size: 12px; }
|
|
206
|
+
.media-record[aria-pressed="true"] { color: #c92a2a; border-color: #c92a2a; }
|
|
207
|
+
.media-download { color: var(--_accent); }
|
|
208
|
+
.media-panel { max-width: 420px; display: grid; gap: 8px; padding: 12px 0 4px; }
|
|
209
|
+
.media-panel[hidden], .media-download[hidden] { display: none; }
|
|
210
|
+
.media-panel label { display: grid; gap: 3px; }
|
|
211
|
+
.media-panel input { min-width: 0; width: 100%; padding: 6px 8px; font: inherit; color: var(--_text); background: var(--_bg); border: 1px solid var(--_line); border-radius: 6px; }
|
|
212
|
+
.media-panel .media-actions { display: flex; gap: 6px; flex-wrap: wrap; }
|
|
213
|
+
.media-status { margin: 4px 0 0; color: var(--_muted); overflow-wrap: anywhere; }
|
|
214
|
+
.media-status.error { color: #c92a2a; }
|
|
215
|
+
.media-note { color: var(--_muted); }
|
|
216
|
+
.body.present .media-controls { position: absolute; top: 12px; right: 12px; left: 12px; width: fit-content; max-width: calc(100% - 24px); border: 1px solid var(--_line); border-radius: 10px; box-shadow: 0 4px 16px rgb(0 0 0 / .2); }
|
|
178
217
|
.live[hidden] { display: none; }
|
|
179
218
|
.live { flex: none; font-size: 11px; padding: 1px 8px; border-radius: 999px; border: 1px solid var(--_line); }
|
|
180
219
|
.live.on { background: var(--_accent); border-color: var(--_accent); color: #fff; }
|
|
@@ -239,6 +278,8 @@ h1 { font-size: 14px; margin: 0; font-weight: 600; }
|
|
|
239
278
|
/* annotate: hover tint, selected outline, request input next to the element */
|
|
240
279
|
.annot-btn { flex: none; font-size: 12px; padding: 2px 10px; border-radius: 999px; }
|
|
241
280
|
.annot-btn[aria-pressed="true"] { background: var(--_accent); border-color: var(--_accent); color: #fff; }
|
|
281
|
+
.sound-btn { flex: none; font-size: 12px; padding: 2px 10px; border-radius: 999px; }
|
|
282
|
+
.sound-btn[aria-pressed="true"] { background: var(--_accent); border-color: var(--_accent); color: #fff; }
|
|
242
283
|
.viewport.annot img { cursor: crosshair; }
|
|
243
284
|
.hl { position: absolute; z-index: 3; pointer-events: none; display: none; border-radius: 2px; }
|
|
244
285
|
.hl.on { display: block; }
|
|
@@ -451,9 +492,22 @@ const TEMPLATE = `
|
|
|
451
492
|
</section>
|
|
452
493
|
<aside class="screen" part="screen">
|
|
453
494
|
<div class="screen-bar"><span class="live" hidden>에이전트 조작 중</span><span class="url"></span>
|
|
495
|
+
<button class="sound-btn" type="button" aria-pressed="false" title="켜면 브라우저 탭 소리가 이 화면에서만 나와요 (에이전트 쪽 브라우저는 음소거)">소리</button>
|
|
454
496
|
<button class="annot-btn" type="button" aria-pressed="false" title="화면의 요소를 골라 에이전트에게 요청 (선택자와 함께 전달)">주석</button>
|
|
455
497
|
<button class="control-btn" role="switch" aria-checked="false" title="켜면 마우스와 키보드 입력이 브라우저로 전달돼요 (끄기: Shift+Esc)">
|
|
456
498
|
<span>내 조작</span><span class="track"><span class="knob"></span></span><span class="state-label">OFF</span></button></div>
|
|
499
|
+
<div class="media-controls" aria-label="발표 설정 및 녹화">
|
|
500
|
+
<div class="media-toolbar"><button type="button" class="media-settings" aria-expanded="false">발표 설정</button>
|
|
501
|
+
<button type="button" class="media-play">대본 재생</button><button type="button" class="media-record" aria-pressed="false">녹화 시작</button>
|
|
502
|
+
<span class="media-elapsed"></span><a class="media-download" hidden>영상 저장</a></div>
|
|
503
|
+
<div class="media-panel" hidden role="group" aria-label="TTS 설정">
|
|
504
|
+
<label>Typecast API 키<input class="media-key" type="password" autocomplete="off" placeholder="키는 서버 메모리에만 보관해요"></label>
|
|
505
|
+
<label>목소리 ID<input class="media-voice" type="text" spellcheck="false" value="${DEFAULT_VOICE}"></label>
|
|
506
|
+
<div class="media-actions"><button type="button" class="media-save">키·목소리 적용</button><button type="button" class="media-local">로컬 키 불러오기</button><button type="button" class="media-clear">키 삭제</button></div>
|
|
507
|
+
<button type="button" class="media-tts" aria-pressed="false">TTS 켜기</button>
|
|
508
|
+
<span class="media-note">TTS와 녹화는 각각 선택해요. 발표 화면 이미지와 대본을 녹화하며, TTS 음성은 TTS를 켠 제어 화면의 녹화에만 담겨요. 최대 15분·256MB.</span>
|
|
509
|
+
</div><p class="media-status" role="status" aria-live="polite"></p>
|
|
510
|
+
</div>
|
|
457
511
|
<div class="viewport"><div class="ring"></div><div class="glow"></div><div class="agent-badge">에이전트 조작 중</div><img alt="에이전트 브라우저 화면" draggable="false"><textarea class="kbd" aria-label="브라우저 키보드 입력"></textarea><div class="placeholder">열린 terminal-browser 없음</div><div class="hl hover"></div><div class="hl sel"></div><div class="hl-label"></div>
|
|
458
512
|
<form class="note" part="note" hidden><div class="note-target"></div><textarea aria-label="선택한 요소에 대한 요청" placeholder="이 요소에 대한 요청 (Enter 전송, Esc 취소)"></textarea>
|
|
459
513
|
<div class="note-row"><span class="note-hint"></span><button type="submit" class="primary">보내기</button></div></form><div class="captions" part="captions" aria-live="polite"></div>
|
|
@@ -479,6 +533,7 @@ class AgentBridge extends HTMLElement {
|
|
|
479
533
|
#root = this.attachShadow({ mode: "open" });
|
|
480
534
|
#es = null;
|
|
481
535
|
#screenEs = null;
|
|
536
|
+
#sound = null; // page audio: { ctx, es, next: Map<stream id, end time> }
|
|
482
537
|
#ws = null; // /input: control input and annotate inspect queries
|
|
483
538
|
#control = false;
|
|
484
539
|
#annot = false;
|
|
@@ -504,6 +559,10 @@ class AgentBridge extends HTMLElement {
|
|
|
504
559
|
#historyKey = null;
|
|
505
560
|
#history = [];
|
|
506
561
|
#present = { on: false };
|
|
562
|
+
#viewerId = crypto.randomUUID();
|
|
563
|
+
#media;
|
|
564
|
+
#mediaBusy = false;
|
|
565
|
+
#mediaConnection = 0;
|
|
507
566
|
static HISTORY_LIMIT = 500;
|
|
508
567
|
static CAPTION_MS = 10000; // presentation bubble lifetime
|
|
509
568
|
static EVENT_MS = 5000; // system event bubble lifetime
|
|
@@ -522,7 +581,7 @@ class AgentBridge extends HTMLElement {
|
|
|
522
581
|
body: q(".body"), screen: q(".screen"), captions: q(".captions"),
|
|
523
582
|
busy: q(".busy"), hand: q(".hand"), end: q(".end"), ask: q(".ask"), askBox: q(".ask textarea"), askHint: q(".ask-hint"),
|
|
524
583
|
askTitle: q(".ask-title"), askDone: q(".ask-done"),
|
|
525
|
-
annotBtn: q(".annot-btn"), hlHover: q(".hl.hover"), hlSel: q(".hl.sel"), hlLabel: q(".hl-label"),
|
|
584
|
+
annotBtn: q(".annot-btn"), soundBtn: q(".sound-btn"), hlHover: q(".hl.hover"), hlSel: q(".hl.sel"), hlLabel: q(".hl-label"),
|
|
526
585
|
note: q(".note"), noteBox: q(".note textarea"), noteHint: q(".note-hint"), noteTarget: q(".note-target"),
|
|
527
586
|
};
|
|
528
587
|
this.$.buttons.forEach((b) => b.addEventListener("click", () => this.#submit(b.dataset.action)));
|
|
@@ -536,11 +595,13 @@ class AgentBridge extends HTMLElement {
|
|
|
536
595
|
loadMarkdown().then((md) => (this.#md = md));
|
|
537
596
|
this.#bindControl();
|
|
538
597
|
this.#bindAnnotate();
|
|
598
|
+
this.$.soundBtn.addEventListener("click", () => this.#setSound(!this.#sound));
|
|
539
599
|
this.#bindHand();
|
|
540
600
|
q(".term-btn").addEventListener("click", () => this.#showTerminal(!this.hasAttribute("terminal-open")));
|
|
541
601
|
q(".terminal-retry").addEventListener("click", () => this.#showTerminal(true));
|
|
542
602
|
this.#bindPanes();
|
|
543
603
|
this.#bindImages();
|
|
604
|
+
this.#bindMedia();
|
|
544
605
|
}
|
|
545
606
|
|
|
546
607
|
get server() {
|
|
@@ -553,17 +614,21 @@ class AgentBridge extends HTMLElement {
|
|
|
553
614
|
this.#syncScreen();
|
|
554
615
|
}
|
|
555
616
|
disconnectedCallback() {
|
|
617
|
+
this.#mediaConnection++;
|
|
618
|
+
this.#media.reset(this.server, true);
|
|
619
|
+
this.$.captions.replaceChildren();
|
|
556
620
|
this.#showTerminal(false);
|
|
557
621
|
this.#es?.close();
|
|
558
622
|
this.#es = null;
|
|
559
623
|
this.#screenEs?.close();
|
|
560
624
|
this.#screenEs = null;
|
|
625
|
+
this.#setSound(false);
|
|
561
626
|
this.#setControl(false);
|
|
562
627
|
this.#setAnnotate(false);
|
|
563
628
|
}
|
|
564
629
|
attributeChangedCallback(name) {
|
|
565
630
|
if (name === "heading") this.$.heading.textContent = this.getAttribute("heading") || "agello";
|
|
566
|
-
if (name === "server" && this.isConnected) { this.#showTerminal(false); this.#openPanes(false); this.#connect(); this.#screenEs?.close(); this.#screenEs = null; this.#syncScreen(); }
|
|
631
|
+
if (name === "server" && this.isConnected) { this.#showTerminal(false); this.#openPanes(false); this.#connect(); this.#screenEs?.close(); this.#screenEs = null; this.#setSound(false); this.#syncScreen(); }
|
|
567
632
|
if (name === "screen" && this.isConnected) this.#syncScreen();
|
|
568
633
|
}
|
|
569
634
|
|
|
@@ -875,7 +940,13 @@ class AgentBridge extends HTMLElement {
|
|
|
875
940
|
|
|
876
941
|
#connect() {
|
|
877
942
|
this.#es?.close();
|
|
878
|
-
|
|
943
|
+
this.#media.reset(this.server);
|
|
944
|
+
this.$.captions.replaceChildren();
|
|
945
|
+
const version = ++this.#mediaConnection;
|
|
946
|
+
const es = (this.#es = new EventSource(`${this.server}/events?viewer=${encodeURIComponent(this.#viewerId)}`));
|
|
947
|
+
this.#media.request("/tts").then((config) => {
|
|
948
|
+
if (version === this.#mediaConnection) this.#media.setConfig(config);
|
|
949
|
+
}).catch((error) => { if (version === this.#mediaConnection) this.#media.report(error); });
|
|
879
950
|
const on = (name, fn) => es.addEventListener(name, (e) => fn(JSON.parse(e.data)));
|
|
880
951
|
on("hello", (d) => {
|
|
881
952
|
if (!d.transcript) this.$.hint.textContent = "대화 기록 파일 없음: 메시지 표시 불가";
|
|
@@ -885,10 +956,15 @@ class AgentBridge extends HTMLElement {
|
|
|
885
956
|
on("unqueued", ({ id }) => { this.#unqueue(id); this.#emit("agent-queue", { phase: "unqueued", id }); });
|
|
886
957
|
on("status", (s) => this.#renderStatus(s));
|
|
887
958
|
on("message", (m) => {
|
|
888
|
-
|
|
889
|
-
this
|
|
959
|
+
// Joining during speech replays its caption snapshot, which may already
|
|
960
|
+
// be in this pane's saved history. Keep the live caption without a duplicate row.
|
|
961
|
+
const replayedNarration = typeof m.narration === "string" && this.#history.some((saved) => saved.narration === m.narration);
|
|
962
|
+
if (!replayedNarration) {
|
|
963
|
+
this.#addMessage(m);
|
|
964
|
+
this.#saveMessage(m);
|
|
965
|
+
}
|
|
890
966
|
// script lines carry their own on-screen time (>= 10s), others use CAPTION_MS
|
|
891
|
-
if (this.#present.on && m.role === "assistant") this.#caption(m.text, "agent", m.hold * 1000 || undefined);
|
|
967
|
+
if (this.#present.on && m.role === "assistant") this.#caption(m.text, "agent", m.hold * 1000 || undefined, typeof m.narration === "string" ? m.narration : null);
|
|
892
968
|
if (this.#present.on && m.role === "browser" && m.action === "message") this.#caption(m.text, "user");
|
|
893
969
|
// a reply to the viewer's question (status polling may miss a short working phase)
|
|
894
970
|
// (the agent took the question once it is echoed in the transcript; earlier work was the hand-raise turn)
|
|
@@ -897,6 +973,8 @@ class AgentBridge extends HTMLElement {
|
|
|
897
973
|
this.#emit("agent-message", m);
|
|
898
974
|
});
|
|
899
975
|
on("present", (p) => this.#setPresent(p));
|
|
976
|
+
on("tts", (config) => this.#media.setConfig(config));
|
|
977
|
+
on("speech", (event) => { void this.#media.handleSpeech(event); });
|
|
900
978
|
on("tool_start", (t) => {
|
|
901
979
|
this.#tools.set(t.id, t);
|
|
902
980
|
this.#renderActivity();
|
|
@@ -913,12 +991,17 @@ class AgentBridge extends HTMLElement {
|
|
|
913
991
|
this.#setToolState(t.id, t.isError ? "error" : "done");
|
|
914
992
|
this.#emit("agent-tool", { phase: "end", ...t });
|
|
915
993
|
});
|
|
916
|
-
es.onerror = () => {
|
|
994
|
+
es.onerror = () => {
|
|
995
|
+
this.#mediaConnection++;
|
|
996
|
+
this.#media.reset(this.server);
|
|
997
|
+
this.#renderStatus(null); this.#setPresent({ on: false });
|
|
998
|
+
this.#media.report("서버 연결이 끊겨 음성과 녹화를 종료했어요. 다시 연결한 뒤 TTS를 켜 주세요.");
|
|
999
|
+
};
|
|
917
1000
|
}
|
|
918
1001
|
|
|
919
1002
|
#syncScreen() {
|
|
920
1003
|
const want = this.hasAttribute("screen") || this.#present.on;
|
|
921
|
-
if (!want) { this.#screenEs?.close(); this.#screenEs = null; this.#setControl(false); this.#setAnnotate(false); return; }
|
|
1004
|
+
if (!want) { this.#screenEs?.close(); this.#screenEs = null; this.#setSound(false); this.#setControl(false); this.#setAnnotate(false); return; }
|
|
922
1005
|
if (this.#screenEs) return;
|
|
923
1006
|
const es = (this.#screenEs = new EventSource(`${this.server}/screen`));
|
|
924
1007
|
es.addEventListener("meta", (e) => this.#renderScreenMeta(JSON.parse(e.data)));
|
|
@@ -932,12 +1015,149 @@ class AgentBridge extends HTMLElement {
|
|
|
932
1015
|
es.onerror = () => this.#renderScreenMeta({ connected: false });
|
|
933
1016
|
}
|
|
934
1017
|
|
|
1018
|
+
// ---------- page audio ----------
|
|
1019
|
+
// On: the relayed tab's audio plays here and is muted in the agent's
|
|
1020
|
+
// browser (while any viewer has it on). The click creates the AudioContext,
|
|
1021
|
+
// which is what the browser's autoplay policy needs.
|
|
1022
|
+
#setSound(on) {
|
|
1023
|
+
if (!on) {
|
|
1024
|
+
if (!this.#sound) return;
|
|
1025
|
+
this.#sound.es.close();
|
|
1026
|
+
this.#sound.ctx.close().catch(() => {});
|
|
1027
|
+
this.#sound = null;
|
|
1028
|
+
} else {
|
|
1029
|
+
if (this.#sound) return;
|
|
1030
|
+
const ctx = new AudioContext();
|
|
1031
|
+
ctx.resume().catch(() => {});
|
|
1032
|
+
const es = new EventSource(`${this.server}/screen/audio`);
|
|
1033
|
+
const s = (this.#sound = { ctx, es, next: new Map() });
|
|
1034
|
+
es.addEventListener("pcm", (e) => this.#playPcm(s, JSON.parse(e.data)));
|
|
1035
|
+
}
|
|
1036
|
+
this.$.soundBtn.setAttribute("aria-pressed", String(!!this.#sound));
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
// One chunk: 16-bit little-endian stereo. Chunks of a stream are queued
|
|
1040
|
+
// back to back; a gap or a backlog over 1s restarts with a 150ms buffer.
|
|
1041
|
+
#playPcm(s, { c, r, d }) {
|
|
1042
|
+
if (this.#sound !== s) return;
|
|
1043
|
+
const bin = atob(d);
|
|
1044
|
+
const pcm = new Int16Array(bin.length >> 1);
|
|
1045
|
+
for (let i = 0; i < pcm.length; i++) pcm[i] = (bin.charCodeAt(2 * i) | (bin.charCodeAt(2 * i + 1) << 8)) << 16 >> 16;
|
|
1046
|
+
const n = pcm.length >> 1;
|
|
1047
|
+
if (!n) return;
|
|
1048
|
+
const buf = s.ctx.createBuffer(2, n, r);
|
|
1049
|
+
const L = buf.getChannelData(0), R = buf.getChannelData(1);
|
|
1050
|
+
for (let i = 0; i < n; i++) { L[i] = pcm[2 * i] / 32768; R[i] = pcm[2 * i + 1] / 32768; }
|
|
1051
|
+
const src = s.ctx.createBufferSource();
|
|
1052
|
+
src.buffer = buf;
|
|
1053
|
+
src.connect(s.ctx.destination);
|
|
1054
|
+
const now = s.ctx.currentTime;
|
|
1055
|
+
let at = s.next.get(c) ?? 0;
|
|
1056
|
+
if (at < now + 0.02 || at > now + 1) at = now + 0.15;
|
|
1057
|
+
src.start(at);
|
|
1058
|
+
s.next.set(c, at + buf.duration);
|
|
1059
|
+
}
|
|
1060
|
+
|
|
935
1061
|
// ---------- presentation mode ----------
|
|
936
1062
|
|
|
1063
|
+
#bindMedia() {
|
|
1064
|
+
const q = (selector) => this.#root.querySelector(selector);
|
|
1065
|
+
this.#media = new PresentationMedia({
|
|
1066
|
+
viewerId: this.#viewerId, image: this.$.img,
|
|
1067
|
+
captions: () => [...this.$.captions.children].filter((el) => !el.classList.contains("out")).map((el) => ({ text: el.textContent, kind: el.__captionKind || "agent" })),
|
|
1068
|
+
onChange: () => this.#renderMedia(),
|
|
1069
|
+
onSpeech: ({ id, phase }) => {
|
|
1070
|
+
if (["ended", "cancel", "error"].includes(phase)) {
|
|
1071
|
+
for (const el of this.$.captions.children) if (el.dataset.narration === id) el.__leave?.();
|
|
1072
|
+
}
|
|
1073
|
+
},
|
|
1074
|
+
});
|
|
1075
|
+
this.$.img.addEventListener("load", () => this.#media.flushRecordingFrame());
|
|
1076
|
+
q(".media-settings").addEventListener("click", () => {
|
|
1077
|
+
const panel = q(".media-panel");
|
|
1078
|
+
panel.hidden = !panel.hidden;
|
|
1079
|
+
q(".media-settings").setAttribute("aria-expanded", String(!panel.hidden));
|
|
1080
|
+
if (!panel.hidden) q(".media-key").focus();
|
|
1081
|
+
});
|
|
1082
|
+
const run = async (fn) => {
|
|
1083
|
+
if (this.#mediaBusy) return;
|
|
1084
|
+
this.#mediaBusy = true;
|
|
1085
|
+
this.#renderMedia();
|
|
1086
|
+
try { await fn(); } catch (error) { this.#media.report(error); }
|
|
1087
|
+
finally { this.#mediaBusy = false; this.#renderMedia(); }
|
|
1088
|
+
};
|
|
1089
|
+
q(".media-save").addEventListener("click", () => {
|
|
1090
|
+
const input = q(".media-key");
|
|
1091
|
+
const apiKey = input.value.trim();
|
|
1092
|
+
input.value = "";
|
|
1093
|
+
void run(() => this.#media.configure({ ...(apiKey ? { apiKey } : {}), voiceId: q(".media-voice").value.trim() }));
|
|
1094
|
+
});
|
|
1095
|
+
q(".media-local").addEventListener("click", () => { q(".media-key").value = ""; void run(() => this.#media.configure({ useLocalKey: true, voiceId: q(".media-voice").value.trim() })); });
|
|
1096
|
+
q(".media-clear").addEventListener("click", () => { q(".media-key").value = ""; void run(() => this.#media.configure({ clearKey: true })); });
|
|
1097
|
+
q(".media-tts").addEventListener("click", () => {
|
|
1098
|
+
const enabled = !(this.#media.config.enabled && this.#media.config.driverId === this.#viewerId);
|
|
1099
|
+
// Invoke resume while this click still holds browser user activation.
|
|
1100
|
+
void run(async () => {
|
|
1101
|
+
if (enabled) await this.#media.unlock();
|
|
1102
|
+
await this.#media.configure({ enabled, voiceId: q(".media-voice").value.trim() });
|
|
1103
|
+
});
|
|
1104
|
+
});
|
|
1105
|
+
q(".media-play").addEventListener("click", () => {
|
|
1106
|
+
const cmd = this.#present.player?.state === "playing" ? "pause" : "resume";
|
|
1107
|
+
if (cmd === "pause") this.#media.pauseSpeech();
|
|
1108
|
+
else this.#media.resumeSpeech();
|
|
1109
|
+
void run(async () => {
|
|
1110
|
+
if (cmd === "resume" && this.#present.player?.state === "done") await this.#media.request("/player", { cmd: "goto", at: "1" });
|
|
1111
|
+
const data = await this.#media.request("/player", { cmd });
|
|
1112
|
+
this.#present.player = data.player;
|
|
1113
|
+
});
|
|
1114
|
+
});
|
|
1115
|
+
q(".media-record").addEventListener("click", () => {
|
|
1116
|
+
try {
|
|
1117
|
+
if (this.#media.recording) this.#media.stopRecording();
|
|
1118
|
+
else this.#media.startRecording();
|
|
1119
|
+
} catch (error) { this.#media.report(error); }
|
|
1120
|
+
});
|
|
1121
|
+
this.#renderMedia();
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
#renderMedia() {
|
|
1125
|
+
if (!this.#media) return;
|
|
1126
|
+
const q = (selector) => this.#root.querySelector(selector);
|
|
1127
|
+
const media = this.#media, config = media.config;
|
|
1128
|
+
const recording = media.recording;
|
|
1129
|
+
const own = config.enabled && config.driverId === this.#viewerId;
|
|
1130
|
+
const tts = q(".media-tts");
|
|
1131
|
+
tts.textContent = own ? "TTS 끄기" : config.enabled ? "이 화면에서 TTS 켜기" : "TTS 켜기";
|
|
1132
|
+
tts.setAttribute("aria-pressed", String(own));
|
|
1133
|
+
for (const selector of [".media-tts", ".media-save", ".media-local", ".media-clear", ".media-voice"]) q(selector).disabled = this.#mediaBusy || !!recording;
|
|
1134
|
+
const play = q(".media-play");
|
|
1135
|
+
play.textContent = this.#present.player?.state === "playing" ? "일시정지" : this.#present.player?.state === "done" ? "다시 재생" : "대본 재생";
|
|
1136
|
+
play.disabled = this.#mediaBusy || !this.#present.player?.steps;
|
|
1137
|
+
const record = q(".media-record");
|
|
1138
|
+
record.textContent = recording?.stopping ? "영상 저장 중…" : recording ? "녹화 종료" : "녹화 시작";
|
|
1139
|
+
record.setAttribute("aria-pressed", String(!!recording));
|
|
1140
|
+
record.disabled = !!recording?.stopping;
|
|
1141
|
+
const seconds = recording ? Math.floor((performance.now() - recording.started) / 1000) : 0;
|
|
1142
|
+
q(".media-elapsed").textContent = recording ? `${String(Math.floor(seconds / 60)).padStart(2, "0")}:${String(seconds % 60).padStart(2, "0")}` : "";
|
|
1143
|
+
const link = q(".media-download");
|
|
1144
|
+
link.hidden = !media.download;
|
|
1145
|
+
if (media.download) { link.href = media.download.url; link.download = media.download.name; }
|
|
1146
|
+
else { link.removeAttribute("href"); link.removeAttribute("download"); }
|
|
1147
|
+
const status = q(".media-status");
|
|
1148
|
+
status.classList.toggle("error", !!media.error);
|
|
1149
|
+
const errorText = Object.hasOwn(MEDIA_ERRORS, media.error) ? MEDIA_ERRORS[media.error] : media.error;
|
|
1150
|
+
status.textContent = errorText || (own ? "TTS ON · 이 화면에서 음성 재생" : config.enabled ? "TTS ON · 다른 화면에서 음성 재생" : config.configured ? "TTS OFF · API 키 준비됨" : "TTS OFF · API 키 미설정");
|
|
1151
|
+
}
|
|
1152
|
+
|
|
937
1153
|
#setPresent(p) {
|
|
938
1154
|
const on = !!p?.on;
|
|
939
1155
|
const was = this.#present.on;
|
|
940
1156
|
this.#present = p ?? { on: false };
|
|
1157
|
+
this.#media.observePlayer(p?.player?.state);
|
|
1158
|
+
if (!on) this.#media.cancelSpeech();
|
|
1159
|
+
if (!on) this.#media.stopRecording();
|
|
1160
|
+
this.#renderMedia();
|
|
941
1161
|
this.#emit("agent-present", this.#present);
|
|
942
1162
|
this.#renderBusy();
|
|
943
1163
|
if (on === was) return;
|
|
@@ -1009,6 +1229,7 @@ class AgentBridge extends HTMLElement {
|
|
|
1009
1229
|
|
|
1010
1230
|
// Raise: open the input and tell the agent to pause. Failures only show a hint; asking still works.
|
|
1011
1231
|
async #raiseHand() {
|
|
1232
|
+
this.#media.pauseSpeech();
|
|
1012
1233
|
this.#event("손들기 확인 · 발표 일시정지");
|
|
1013
1234
|
this.#qa = "raised";
|
|
1014
1235
|
this.#openAsk("raised");
|
|
@@ -1078,22 +1299,26 @@ class AgentBridge extends HTMLElement {
|
|
|
1078
1299
|
|
|
1079
1300
|
// Agent reply as a short-lived bubble over the presented screen.
|
|
1080
1301
|
// kind "user": the viewer's own question (plain text, accent colour).
|
|
1081
|
-
#caption(text, kind = "agent", ms = AgentBridge.CAPTION_MS) {
|
|
1302
|
+
#caption(text, kind = "agent", ms = AgentBridge.CAPTION_MS, narration = null) {
|
|
1082
1303
|
if (!text?.trim()) return;
|
|
1083
1304
|
const el = document.createElement("div");
|
|
1084
1305
|
el.className = `caption ${kind}`;
|
|
1085
1306
|
el.part = "caption";
|
|
1307
|
+
if (narration) el.dataset.narration = narration;
|
|
1308
|
+
el.__captionKind = kind;
|
|
1086
1309
|
if (kind === "user" || kind === "event") { el.textContent = text; el.classList.add("plain"); }
|
|
1087
1310
|
else if (this.#md) el.innerHTML = this.#md(text);
|
|
1088
1311
|
else { el.textContent = text; el.classList.add("plain"); }
|
|
1089
1312
|
const box = this.$.captions;
|
|
1090
1313
|
box.append(el);
|
|
1314
|
+
this.#media.flushRecordingFrame();
|
|
1091
1315
|
const leave = () => {
|
|
1092
1316
|
if (el.classList.contains("out")) return;
|
|
1093
1317
|
el.classList.add("out");
|
|
1318
|
+
this.#media.flushRecordingFrame();
|
|
1094
1319
|
setTimeout(() => el.remove(), 500);
|
|
1095
1320
|
};
|
|
1096
|
-
setTimeout(leave, ms);
|
|
1321
|
+
if (!narration) setTimeout(leave, ms);
|
|
1097
1322
|
const live = [...box.children].filter((c) => !c.classList.contains("out"));
|
|
1098
1323
|
live.slice(0, Math.max(0, live.length - AgentBridge.CAPTION_MAX)).forEach((c) => c.__leave?.());
|
|
1099
1324
|
el.__leave = leave;
|