diff --git a/broadcast.ts b/broadcast.ts index a2cb689..9fedb94 100644 --- a/broadcast.ts +++ b/broadcast.ts @@ -191,15 +191,53 @@ function textLines( const words = text.split(/\s+/).filter(Boolean); const lines: string[] = []; let current = ""; + const maxChunkLength = 1; + + const splitWordToFit = (word: string): string[] => { + if (!word) return []; + if (ctx.measureText(word).width <= maxWidth) return [word]; + + const pieces: string[] = []; + let remaining = word; + while (remaining.length > 0) { + let low = maxChunkLength; + let high = remaining.length; + let best = maxChunkLength; + + while (low <= high) { + const mid = Math.floor((low + high) / 2); + const candidate = remaining.slice(0, mid); + if (ctx.measureText(candidate).width <= maxWidth) { + best = mid; + low = mid + 1; + } else { + high = mid - 1; + } + } + + pieces.push(remaining.slice(0, best)); + remaining = remaining.slice(best); + } + + return pieces; + }; for (const word of words) { - const candidate = current ? `${current} ${word}` : word; - if (ctx.measureText(candidate).width <= maxWidth) { - current = candidate; - continue; + const segments = splitWordToFit(word); + let isFirstSegment = true; + for (const segment of segments) { + const prefix = isFirstSegment && current ? " " : ""; + const candidate = current ? `${current}${prefix}${segment}` : segment; + if (ctx.measureText(candidate).width <= maxWidth) { + current = candidate; + isFirstSegment = false; + continue; + } + if (current) lines.push(current); + current = segment; + isFirstSegment = false; + if (lines.length >= maxLines - 1) break; } - if (current) lines.push(current); - current = word; if (lines.length >= maxLines - 1) break; } diff --git a/scripts/stream-browser.ts b/scripts/stream-browser.ts index f8d3c9e..0fd2b4e 100644 --- a/scripts/stream-browser.ts +++ b/scripts/stream-browser.ts @@ -149,6 +149,7 @@ async function main() { stdout: mode === "dryrun" ? "pipe" : "inherit", stderr: "inherit", }); + let ffmpegWritable = true; let ffplay: Bun.Subprocess | null = null; let ffplayPump: Promise | null = null; @@ -187,6 +188,7 @@ async function main() { firstChunkResolve = resolve; firstChunkReject = reject; }); + let shutdown: (() => Promise) | null = null; const chunkServer = Bun.serve({ port: 0, @@ -199,7 +201,9 @@ async function main() { }, websocket: { message(_ws, message) { - if (!ffmpeg.stdin) return; + if (!ffmpegWritable || !ffmpeg.stdin || typeof ffmpeg.stdin === "number") { + return; + } if (typeof message === "string") return; let chunk: Uint8Array | null = null; @@ -220,8 +224,12 @@ async function main() { firstChunkResolve = null; firstChunkReject = null; } catch (error) { + ffmpegWritable = false; const detail = error instanceof Error ? error : new Error(String(error)); firstChunkReject?.(detail); + firstChunkResolve = null; + firstChunkReject = null; + void shutdown?.(); } }, }, @@ -263,9 +271,10 @@ async function main() { console.log(`Streaming from ${broadcastUrl} in ${mode} mode`); let shuttingDown = false; - const shutdown = async () => { + shutdown = async () => { if (shuttingDown) return; shuttingDown = true; + ffmpegWritable = false; try { chunkServer.stop(true); } catch {} @@ -290,14 +299,20 @@ async function main() { } }; - process.on("SIGINT", () => { - void shutdown(); - }); - process.on("SIGTERM", () => { - void shutdown(); + const ffmpegExit = ffmpeg.exited.then((code) => { + ffmpegWritable = false; + void shutdown?.(); + return code; }); - const exitCode = await ffmpeg.exited; + process.on("SIGINT", () => { + void shutdown?.(); + }); + process.on("SIGTERM", () => { + void shutdown?.(); + }); + + const exitCode = await ffmpegExit; if (ffplayPump) { await ffplayPump.catch(() => { // Ignore downstream pipe failures on shutdown. @@ -306,7 +321,7 @@ async function main() { if (ffplay) { await ffplay.exited; } - await shutdown(); + await shutdown?.(); if (exitCode !== 0) { process.exit(exitCode); diff --git a/todo.md b/todo.md index 03a07da..3d9026f 100644 --- a/todo.md +++ b/todo.md @@ -5,6 +5,6 @@ ## TODO Bugfixes -- [ ] Text wrapping on long single-strings -- [ ] Stream crashing on fresh deployment (because the ffmpeg pipe dies, we should handle this in the ffmpeg script) -- [ ] Throttle view count updates +- [x] Text wrapping on long single-strings +- [x] Stream crashing on fresh deployment (because the ffmpeg pipe dies, we should handle this in the ffmpeg script) +- [x] Throttle view count updates