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.
@@ -0,0 +1,459 @@
1
+ // Browser-only presentation media. Credentials never enter history or browser storage.
2
+ export const DEFAULT_VOICE = "tc_6a0e85a97f7750959b970d5d";
3
+ const MIME_TYPES = ["video/mp4;codecs=avc1.42E01E,mp4a.40.2", "video/mp4", "video/webm;codecs=vp9,opus", "video/webm;codecs=vp8,opus", "video/webm"];
4
+ export function recordingMime(Recorder) {
5
+ return MIME_TYPES.find((type) => Recorder.isTypeSupported(type)) || "";
6
+ }
7
+
8
+ // MediaRecorder's streaming WebM omits Duration. Patch only the small EBML
9
+ // header, retaining cluster bytes as Blob slices instead of copying the video.
10
+ function ebmlElement(bytes, offset) {
11
+ const variable = (at, strip) => {
12
+ if (at >= bytes.length || !bytes[at]) return null;
13
+ let length = 1;
14
+ while (length <= 8 && !(bytes[at] & (0x80 >> (length - 1)))) length++;
15
+ if (length > 8 || at + length > bytes.length) return null;
16
+ let value = BigInt(strip ? bytes[at] & ((0x80 >> (length - 1)) - 1) : bytes[at]);
17
+ for (let i = 1; i < length; i++) value = (value << 8n) | BigInt(bytes[at + i]);
18
+ return { value, length, unknown: strip && value === (1n << BigInt(7 * length)) - 1n };
19
+ };
20
+ const id = variable(offset, false);
21
+ if (!id) return null;
22
+ const size = variable(offset + id.length, true);
23
+ if (!size) return null;
24
+ const body = offset + id.length + size.length;
25
+ return { id: Number(id.value), size: size.unknown ? Infinity : Number(size.value), sizeAt: offset + id.length, sizeLength: size.length, body, end: size.unknown ? Infinity : body + Number(size.value) };
26
+ }
27
+
28
+ function ebmlWriteSize(bytes, at, length, value) {
29
+ let integer = BigInt(value);
30
+ if (integer < 0 || integer >= (1n << BigInt(length * 7)) - 1n) return false;
31
+ for (let i = length - 1; i >= 0; i--) { bytes[at + i] = Number(integer & 255n); integer >>= 8n; }
32
+ bytes[at] |= 0x80 >> (length - 1);
33
+ return true;
34
+ }
35
+
36
+ export async function finalizeWebmDuration(blob, elapsedMs, BlobClass = Blob) {
37
+ if (!Number.isFinite(elapsedMs) || elapsedMs <= 0) return blob;
38
+ const bytes = new Uint8Array(await blob.slice(0, 65536).arrayBuffer());
39
+ let at = 0, segment;
40
+ while (at < bytes.length) {
41
+ const element = ebmlElement(bytes, at);
42
+ if (!element) return blob;
43
+ if (element.id === 0x18538067) { segment = element; break; }
44
+ at = element.end;
45
+ }
46
+ if (!segment) return blob;
47
+ let info, scale = 1_000_000, duration;
48
+ const seekHeads = [];
49
+ at = segment.body;
50
+ while (at < bytes.length) {
51
+ const element = ebmlElement(bytes, at);
52
+ if (!element || element.end > bytes.length) break;
53
+ if (element.id === 0x1549a966) info = element;
54
+ if (element.id === 0x114d9b74) seekHeads.push(element);
55
+ at = element.end;
56
+ }
57
+ if (!info) return blob;
58
+ for (at = info.body; at < info.end;) {
59
+ const child = ebmlElement(bytes, at);
60
+ if (!child || child.end > info.end || child.id === 0xbf) return blob; // avoid invalidating CRC metadata
61
+ if (child.id === 0x2ad7b1) {
62
+ scale = 0;
63
+ for (let i = child.body; i < child.end; i++) scale = scale * 256 + bytes[i];
64
+ }
65
+ if (child.id === 0x4489) duration = child;
66
+ at = child.end;
67
+ }
68
+ if (!scale) return blob;
69
+ const value = elapsedMs * 1_000_000 / scale;
70
+ if (duration) {
71
+ const view = new DataView(bytes.buffer);
72
+ if (duration.size === 8) view.setFloat64(duration.body, value);
73
+ else if (duration.size === 4) view.setFloat32(duration.body, value);
74
+ else return blob;
75
+ return new BlobClass([bytes, blob.slice(bytes.length)], { type: blob.type });
76
+ }
77
+ const extra = new Uint8Array(11);
78
+ extra.set([0x44, 0x89, 0x88]); new DataView(extra.buffer).setFloat64(3, value);
79
+ if (!ebmlWriteSize(bytes, info.sizeAt, info.sizeLength, info.size + extra.length)) return blob;
80
+ if (Number.isFinite(segment.size) && !ebmlWriteSize(bytes, segment.sizeAt, segment.sizeLength, segment.size + extra.length)) return blob;
81
+ // Preserve SeekHead positions when a browser includes an index before clusters.
82
+ for (const head of seekHeads) {
83
+ for (at = head.body; at < head.end;) {
84
+ const entry = ebmlElement(bytes, at);
85
+ if (!entry || entry.end > head.end || entry.id === 0xbf) return blob;
86
+ if (entry.id === 0x4dbb) {
87
+ for (let offset = entry.body; offset < entry.end;) {
88
+ const child = ebmlElement(bytes, offset);
89
+ if (!child || child.end > entry.end) return blob;
90
+ if (child.id === 0x53ac) {
91
+ let position = 0n;
92
+ for (let i = child.body; i < child.end; i++) position = (position << 8n) | BigInt(bytes[i]);
93
+ if (position >= BigInt(info.end - segment.body)) {
94
+ position += BigInt(extra.length);
95
+ if (position >= 1n << BigInt(8 * child.size)) return blob;
96
+ for (let i = child.end - 1; i >= child.body; i--) { bytes[i] = Number(position & 255n); position >>= 8n; }
97
+ }
98
+ }
99
+ offset = child.end;
100
+ }
101
+ }
102
+ at = entry.end;
103
+ }
104
+ }
105
+ return new BlobClass([bytes.slice(0, info.end), extra, bytes.slice(info.end), blob.slice(bytes.length)], { type: blob.type });
106
+ }
107
+
108
+ // Character wrapping also works for Korean lines without spaces.
109
+ export function wrapCaption(ctx, text, width) {
110
+ const lines = [];
111
+ for (const paragraph of String(text).split("\n")) {
112
+ let line = "";
113
+ for (const char of Array.from(paragraph)) {
114
+ if (line && ctx.measureText(line + char).width > width) { lines.push(line); line = ""; }
115
+ line += char;
116
+ }
117
+ lines.push(line);
118
+ }
119
+ return lines;
120
+ }
121
+
122
+ export function drawPresentation(ctx, image, captions, width = 1280, height = 720) {
123
+ ctx.fillStyle = "#0b0b0c";
124
+ ctx.fillRect(0, 0, width, height);
125
+ if (image.complete && image.naturalWidth && image.naturalHeight) {
126
+ const scale = Math.min(width / image.naturalWidth, height / image.naturalHeight);
127
+ const w = image.naturalWidth * scale, h = image.naturalHeight * scale;
128
+ ctx.drawImage(image, (width - w) / 2, (height - h) / 2, w, h);
129
+ }
130
+ let bottom = height - 28;
131
+ for (const caption of captions.slice(-3).reverse()) {
132
+ const event = caption.kind === "event";
133
+ const font = event ? 20 : 28, lineHeight = event ? 28 : 38;
134
+ ctx.font = `${font}px system-ui, sans-serif`;
135
+ // Keep the safe area readable even for unusually long script lines.
136
+ const all = wrapCaption(ctx, caption.text, width - 256);
137
+ const lines = all.slice(0, 8);
138
+ if (all.length > lines.length) lines[lines.length - 1] += "…";
139
+ const boxWidth = Math.min(width - 208, Math.max(80, ...lines.map((line) => ctx.measureText(line).width)) + 40);
140
+ const boxHeight = lines.length * lineHeight + 24;
141
+ const x = (width - boxWidth) / 2, y = bottom - boxHeight;
142
+ if (y < 100) break;
143
+ ctx.fillStyle = event ? "rgba(255,255,255,.92)" : caption.kind === "user" ? "rgba(47,111,237,.9)" : "rgba(20,20,22,.82)";
144
+ ctx.beginPath();
145
+ if (ctx.roundRect) ctx.roundRect(x, y, boxWidth, boxHeight, 16);
146
+ else ctx.rect(x, y, boxWidth, boxHeight);
147
+ ctx.fill();
148
+ ctx.fillStyle = event ? "#1d1d1f" : "#fff";
149
+ ctx.textBaseline = "top";
150
+ lines.forEach((line, i) => ctx.fillText(line, x + 20, y + 12 + i * lineHeight));
151
+ bottom = y - 10;
152
+ }
153
+ }
154
+
155
+ export class PresentationMedia {
156
+ constructor({ viewerId, image, captions = () => [], onChange = () => {}, onSpeech = () => {}, env = globalThis }) {
157
+ Object.assign(this, { viewerId, image, captions, onChange, onSpeech, env });
158
+ this.config = { enabled: false, configured: false, voiceId: DEFAULT_VOICE };
159
+ this.server = "";
160
+ this.driverToken = null;
161
+ this.version = 0;
162
+ this.retired = new Set();
163
+ this.recording = null;
164
+ this.download = null;
165
+ this.active = null;
166
+ this.context = null;
167
+ this.error = "";
168
+ this.suspended = false;
169
+ this.localPausePending = false;
170
+ this.disposed = false;
171
+ }
172
+
173
+ changed() { this.onChange(this); }
174
+ report(error) { this.error = String(error?.message || error); this.changed(); }
175
+ async unlock() {
176
+ if (!this.context || this.context.state === "closed") {
177
+ const AudioContext = this.env.AudioContext || this.env.webkitAudioContext;
178
+ if (!AudioContext) throw new Error("이 브라우저는 TTS 음성 재생을 지원하지 않아요.");
179
+ this.context = new AudioContext();
180
+ this.audioDestination = this.context.createMediaStreamDestination();
181
+ // Keep the recording audio track clock running before, between, and
182
+ // after spoken lines. Otherwise a recorder can rebase the first audio
183
+ // packet to zero and omit pauses, moving speech ahead of the captions.
184
+ this.silenceSource = this.context.createConstantSource();
185
+ this.silenceSource.offset.value = 0;
186
+ this.silenceSource.connect(this.audioDestination);
187
+ this.silenceSource.start();
188
+ }
189
+ await this.context.resume();
190
+ if (this.context.state !== "running") throw new Error("음성 재생이 차단됐어요. TTS 켜기를 다시 눌러 주세요.");
191
+ }
192
+
193
+ reset(server, dispose = false) {
194
+ this.version++;
195
+ this.disposed = dispose;
196
+ this.cancelSpeech();
197
+ this.stopRecording();
198
+ this.driverToken = null;
199
+ this.suspended = false;
200
+ this.localPausePending = false;
201
+ this.server = server;
202
+ this.config = { enabled: false, configured: false, voiceId: DEFAULT_VOICE };
203
+ const context = this.context;
204
+ if (this.silenceSource) {
205
+ try { this.silenceSource.stop(); } catch {}
206
+ this.silenceSource.disconnect();
207
+ this.silenceSource = null;
208
+ }
209
+ this.context = null;
210
+ this.audioDestination = null;
211
+ context?.close().catch(() => {});
212
+ if (dispose && this.download) { this.env.URL.revokeObjectURL(this.download.url); this.download = null; }
213
+ this.changed();
214
+ }
215
+
216
+ setConfig(config, token) {
217
+ if (token) this.driverToken = token;
218
+ const ownAudio = !!config.enabled && config.driverId === this.viewerId;
219
+ if (this.recording && this.recording.withAudio !== ownAudio) {
220
+ this.stopRecording();
221
+ this.error = "TTS 설정이 변경되어 녹화를 저장했어요. 새 설정으로 다시 녹화할 수 있어요.";
222
+ }
223
+ this.config = config;
224
+ if (!config.enabled || config.driverId !== this.viewerId) this.cancelSpeech();
225
+ if (config.error) this.error = config.error;
226
+ this.changed();
227
+ }
228
+
229
+ async request(path, body) {
230
+ const response = await this.env.fetch(`${this.server}${path}`, {
231
+ method: body === undefined ? "GET" : "POST",
232
+ ...(body === undefined ? {} : { headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) }),
233
+ });
234
+ const data = await response.json();
235
+ if (!response.ok || data.ok === false) throw new Error(data.error || `HTTP ${response.status}`);
236
+ return data;
237
+ }
238
+
239
+ async configure(body) {
240
+ if (this.recording && body.enabled !== undefined && body.enabled !== this.config.enabled) {
241
+ throw new Error("녹화를 종료한 뒤 TTS를 변경해 주세요. 녹화의 오디오 설정은 시작할 때 정해져요.");
242
+ }
243
+ const version = this.version;
244
+ const data = await this.request("/tts", { ...body, viewerId: this.viewerId });
245
+ if (version === this.version) { this.error = ""; this.setConfig(data.tts, data.driverToken); }
246
+ return data;
247
+ }
248
+
249
+ retire(id) {
250
+ this.retired.add(id);
251
+ if (this.retired.size > 256) this.retired.delete(this.retired.values().next().value);
252
+ }
253
+
254
+ cancelSpeech(id) {
255
+ const active = this.active;
256
+ if (id) this.retire(id);
257
+ if (!active || (id && active.id !== id)) return;
258
+ this.active = null;
259
+ this.retire(active.id);
260
+ active.abort.abort();
261
+ if (active.source) {
262
+ active.source.onended = null;
263
+ try { active.source.stop(); } catch {}
264
+ active.source.disconnect();
265
+ }
266
+ this.onSpeech({ id: active.id, phase: "cancel" });
267
+ }
268
+
269
+ pauseSpeech() {
270
+ this.suspended = true;
271
+ this.localPausePending = true;
272
+ this.cancelSpeech();
273
+ }
274
+
275
+ resumeSpeech() { this.suspended = false; this.localPausePending = false; }
276
+
277
+ observePlayer(state) {
278
+ if (["paused", "done", "empty"].includes(state)) {
279
+ this.suspended = true;
280
+ this.localPausePending = false;
281
+ this.cancelSpeech();
282
+ } else if (state === "playing" && !this.localPausePending) this.suspended = false;
283
+ }
284
+
285
+ async handleSpeech(event) {
286
+ if (event.phase !== "ready") {
287
+ this.cancelSpeech(event.id);
288
+ this.onSpeech(event);
289
+ if (event.phase === "error") this.report(event.error || "TTS 음성 재생 실패");
290
+ return;
291
+ }
292
+ if (!this.config.enabled || event.driverId !== this.viewerId || !this.driverToken || this.suspended || this.retired.has(event.id) || this.active?.id === event.id) return;
293
+ this.cancelSpeech();
294
+ const active = { id: event.id, abort: new AbortController(), source: null };
295
+ this.active = active;
296
+ const server = this.server, token = this.driverToken;
297
+ let ackChain = Promise.resolve();
298
+ const ack = (phase) => {
299
+ ackChain = ackChain.catch(() => {}).then(async () => {
300
+ if (phase !== "error" && (active.abort.signal.aborted || this.server !== server)) return;
301
+ const response = await this.env.fetch(`${server}/tts/ack`, {
302
+ method: "POST", headers: { "Content-Type": "application/json" },
303
+ body: JSON.stringify({ viewerId: this.viewerId, driverToken: token, id: event.id, phase }),
304
+ signal: active.abort.signal,
305
+ });
306
+ if (!response.ok) throw new Error("음성 동기화 연결이 끊겼어요. 발표를 다시 재생해 주세요.");
307
+ const data = await response.json();
308
+ if (data.ok === false) throw new Error(data.error || "음성 동기화 실패");
309
+ if (data.accepted === false && phase === "started") throw new Error("이미 취소된 음성이에요. 발표를 다시 재생해 주세요.");
310
+ });
311
+ return ackChain;
312
+ };
313
+ try {
314
+ if (!this.context || this.context.state !== "running") throw new Error("TTS 켜기를 눌러 이 화면에서 음성 재생을 허용해 주세요.");
315
+ if (!/^\/tts\/audio\/[\w-]+$/.test(event.audioUrl)) throw new Error("잘못된 음성 주소");
316
+ const response = await this.env.fetch(`${server}${event.audioUrl}`, { signal: active.abort.signal });
317
+ if (!response.ok) throw new Error("TTS 음성을 불러오지 못했어요.");
318
+ const buffer = await this.context.decodeAudioData(await response.arrayBuffer());
319
+ if (this.active !== active || active.abort.signal.aborted) return;
320
+ const source = this.context.createBufferSource();
321
+ active.source = source;
322
+ source.buffer = buffer;
323
+ source.connect(this.context.destination);
324
+ source.connect(this.audioDestination);
325
+ source.onended = () => {
326
+ if (this.active !== active) return;
327
+ this.active = null;
328
+ this.retire(event.id);
329
+ source.disconnect();
330
+ this.onSpeech({ id: event.id, phase: "ended" });
331
+ ack("ended").catch(async (error) => {
332
+ if (active.abort.signal.aborted) return;
333
+ try { await ack("error"); } catch {}
334
+ this.report(error);
335
+ });
336
+ };
337
+ source.start();
338
+ this.error = "";
339
+ this.changed();
340
+ await ack("started");
341
+ } catch (error) {
342
+ if (active.abort.signal.aborted || this.server !== server) return;
343
+ // ACK errors must stop audio too; a playing but unacknowledged line cannot advance safely.
344
+ try { await ack("error"); } catch {}
345
+ this.cancelSpeech(event.id);
346
+ this.report(error);
347
+ }
348
+ }
349
+
350
+ startRecording() {
351
+ if (this.recording) return;
352
+ if (this.disposed) throw new Error("화면에 다시 연결한 뒤 녹화해 주세요.");
353
+ const { MediaRecorder } = this.env;
354
+ if (!MediaRecorder) throw new Error("이 브라우저는 화면 녹화를 지원하지 않아요.");
355
+ if (!this.image.complete || !this.image.naturalWidth || !this.image.naturalHeight) throw new Error("화면이 표시된 뒤 녹화를 시작해 주세요.");
356
+ const mime = recordingMime(MediaRecorder);
357
+ if (!mime) throw new Error("이 브라우저에서 지원하는 녹화 형식이 없어요.");
358
+ const canvas = this.env.document.createElement("canvas");
359
+ canvas.width = 1280; canvas.height = 720;
360
+ if (!canvas.captureStream) throw new Error("이 브라우저는 발표 영역 녹화를 지원하지 않아요.");
361
+ const ctx = canvas.getContext("2d");
362
+ if (!ctx) throw new Error("녹화 화면을 만들지 못했어요.");
363
+ drawPresentation(ctx, this.image, this.captions());
364
+ let stream = canvas.captureStream(0);
365
+ let videoTrack = stream.getVideoTracks()[0];
366
+ if (!videoTrack?.requestFrame) {
367
+ stream.getTracks().forEach((track) => track.stop());
368
+ stream = canvas.captureStream(30);
369
+ videoTrack = stream.getVideoTracks()[0];
370
+ }
371
+ let recorder;
372
+ try {
373
+ if (this.config.enabled && this.config.driverId === this.viewerId) {
374
+ if (!this.context || this.context.state !== "running") throw new Error("TTS 켜기를 눌러 음성 재생을 허용한 뒤 녹화해 주세요.");
375
+ this.audioDestination.stream.getAudioTracks().forEach((track) => stream.addTrack(track.clone()));
376
+ }
377
+ recorder = new MediaRecorder(stream, { mimeType: mime, videoBitsPerSecond: 4_000_000 });
378
+ } catch (error) { stream.getTracks().forEach((track) => track.stop()); throw error; }
379
+ const recording = { recorder, stream, chunks: [], bytes: 0, started: this.env.performance.now(), withAudio: !!this.config.enabled && this.config.driverId === this.viewerId, frame: null, timer: null, stopping: false };
380
+ recording.draw = () => {
381
+ drawPresentation(ctx, this.image, this.captions());
382
+ // Explicit frame requests prevent a static canvas from delaying caption
383
+ // changes until the encoder's next sparse frame timestamp.
384
+ videoTrack?.requestFrame?.();
385
+ };
386
+ this.recording = recording;
387
+ recorder.ondataavailable = ({ data }) => {
388
+ if (!data?.size) return;
389
+ // A hard memory bound; finalize what fits instead of growing unbounded.
390
+ if (recording.bytes + data.size > 256 * 1024 * 1024) { this.report("녹화 용량 한도(256MB)에 도달해 저장했어요."); this.stopRecording(); return; }
391
+ recording.chunks.push(data); recording.bytes += data.size;
392
+ // Leave room for the recorder's final chunk (including MP4 metadata).
393
+ if (recording.bytes >= 240 * 1024 * 1024 && !recording.stopping) {
394
+ this.report("녹화 용량 한도(256MB)에 가까워져 저장했어요."); this.stopRecording();
395
+ }
396
+ };
397
+ recorder.onerror = () => { this.report("녹화 중 오류가 발생했어요."); this.stopRecording(); };
398
+ recorder.onstop = async () => {
399
+ this.cleanupRecording(recording);
400
+ if (!this.disposed && recording.bytes) {
401
+ const type = recorder.mimeType || mime;
402
+ const extension = type.startsWith("video/mp4") ? "mp4" : "webm";
403
+ let blob = new this.env.Blob(recording.chunks, { type });
404
+ if (extension === "webm") {
405
+ try { blob = await finalizeWebmDuration(blob, (recording.stoppedAt ?? this.env.performance.now()) - recording.started, this.env.Blob); }
406
+ catch { /* Keep the original browser recording if its metadata cannot be patched. */ }
407
+ }
408
+ if (!this.disposed) {
409
+ if (this.download) this.env.URL.revokeObjectURL(this.download.url);
410
+ this.download = { url: this.env.URL.createObjectURL(blob), name: `agello-${new Date().toISOString().replace(/[:.]/g, "-")}.${extension}`, bytes: blob.size };
411
+ }
412
+ }
413
+ if (this.recording === recording) this.recording = null;
414
+ recording.chunks.length = 0;
415
+ this.changed();
416
+ };
417
+ let lastDraw = -Infinity;
418
+ const draw = (time) => {
419
+ if (recording.stopping || this.recording !== recording) return;
420
+ try {
421
+ if (time - lastDraw >= 1000 / 30) { recording.draw(); lastDraw = time; }
422
+ } catch (error) { this.report(error); this.stopRecording(); return; }
423
+ recording.frame = this.env.requestAnimationFrame(draw);
424
+ };
425
+ try { recorder.start(1000); } catch (error) { this.cleanupRecording(recording); this.recording = null; throw error; }
426
+ recording.draw();
427
+ recording.frame = this.env.requestAnimationFrame(draw);
428
+ recording.timer = this.env.setInterval(() => {
429
+ if (this.env.performance.now() - recording.started >= 15 * 60 * 1000) { this.report("15분 녹화 한도에 도달해 저장했어요."); this.stopRecording(); }
430
+ this.changed();
431
+ }, 1000);
432
+ this.error = "";
433
+ this.changed();
434
+ }
435
+
436
+ cleanupRecording(recording) {
437
+ this.env.cancelAnimationFrame(recording.frame);
438
+ this.env.clearInterval(recording.timer);
439
+ recording.stream.getTracks().forEach((track) => track.stop());
440
+ }
441
+
442
+ flushRecordingFrame() {
443
+ if (!this.recording || this.recording.stopping) return;
444
+ try { this.recording.draw(); }
445
+ catch (error) { this.report(error); this.stopRecording(); }
446
+ }
447
+
448
+ stopRecording() {
449
+ const recording = this.recording;
450
+ if (!recording || recording.stopping) return;
451
+ recording.stopping = true;
452
+ recording.stoppedAt = this.env.performance.now();
453
+ this.env.cancelAnimationFrame(recording.frame);
454
+ this.env.clearInterval(recording.timer);
455
+ if (recording.recorder.state !== "inactive") recording.recorder.stop();
456
+ else { this.cleanupRecording(recording); this.recording = null; }
457
+ this.changed();
458
+ }
459
+ }