broadcast
This commit is contained in:
36
broadcast.css
Normal file
36
broadcast.css
Normal file
@@ -0,0 +1,36 @@
|
||||
html,
|
||||
body {
|
||||
margin: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: #0a0a0a;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
body {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-family: "Inter", sans-serif;
|
||||
color: #ededed;
|
||||
}
|
||||
|
||||
#broadcast-canvas {
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
object-fit: contain;
|
||||
image-rendering: auto;
|
||||
}
|
||||
|
||||
#broadcast-status {
|
||||
position: fixed;
|
||||
left: 16px;
|
||||
bottom: 16px;
|
||||
padding: 8px 10px;
|
||||
border-radius: 8px;
|
||||
background: #111;
|
||||
border: 1px solid #1c1c1c;
|
||||
color: #888;
|
||||
font: 600 12px/1.2 "JetBrains Mono", monospace;
|
||||
letter-spacing: 0.3px;
|
||||
pointer-events: none;
|
||||
}
|
||||
20
broadcast.html
Normal file
20
broadcast.html
Normal file
@@ -0,0 +1,20 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>quipslop Broadcast</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=DM+Serif+Display&family=Inter:wght@400;500;700;900&family=JetBrains+Mono:wght@400;700&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
<link rel="stylesheet" href="./broadcast.css" />
|
||||
</head>
|
||||
<body>
|
||||
<canvas id="broadcast-canvas" width="1920" height="1080"></canvas>
|
||||
<div id="broadcast-status"></div>
|
||||
<script type="module" src="./broadcast.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
606
broadcast.ts
Normal file
606
broadcast.ts
Normal file
@@ -0,0 +1,606 @@
|
||||
type Model = { id: string; name: string };
|
||||
type TaskInfo = {
|
||||
model: Model;
|
||||
startedAt: number;
|
||||
finishedAt?: number;
|
||||
result?: string;
|
||||
error?: string;
|
||||
};
|
||||
type VoteInfo = {
|
||||
voter: Model;
|
||||
startedAt: number;
|
||||
finishedAt?: number;
|
||||
votedFor?: Model;
|
||||
error?: boolean;
|
||||
};
|
||||
type RoundState = {
|
||||
num: number;
|
||||
phase: "prompting" | "answering" | "voting" | "done";
|
||||
prompter: Model;
|
||||
promptTask: TaskInfo;
|
||||
prompt?: string;
|
||||
contestants: [Model, Model];
|
||||
answerTasks: [TaskInfo, TaskInfo];
|
||||
votes: VoteInfo[];
|
||||
scoreA?: number;
|
||||
scoreB?: number;
|
||||
};
|
||||
type GameState = {
|
||||
completed: RoundState[];
|
||||
active: RoundState | null;
|
||||
scores: Record<string, number>;
|
||||
done: boolean;
|
||||
isPaused: boolean;
|
||||
generation: number;
|
||||
};
|
||||
type ServerMessage = {
|
||||
type: "state";
|
||||
data: GameState;
|
||||
totalRounds: number;
|
||||
viewerCount: number;
|
||||
};
|
||||
|
||||
const MODEL_COLORS: Record<string, string> = {
|
||||
"Gemini 3.1 Pro": "#4285F4",
|
||||
"Kimi K2": "#00E599",
|
||||
"DeepSeek 3.2": "#4D6BFE",
|
||||
"GLM-5": "#1F63EC",
|
||||
"GPT-5.2": "#10A37F",
|
||||
"Opus 4.6": "#D97757",
|
||||
"Sonnet 4.6": "#D97757",
|
||||
"Grok 4.1": "#FFFFFF",
|
||||
"MiniMax 2.5": "#FF3B30",
|
||||
};
|
||||
|
||||
const WIDTH = 1920;
|
||||
const HEIGHT = 1080;
|
||||
|
||||
const canvas = document.getElementById("broadcast-canvas") as HTMLCanvasElement;
|
||||
const statusEl = document.getElementById("broadcast-status") as HTMLDivElement;
|
||||
|
||||
function get2dContext(el: HTMLCanvasElement): CanvasRenderingContext2D {
|
||||
const context = el.getContext("2d");
|
||||
if (!context) throw new Error("2D canvas context unavailable");
|
||||
return context;
|
||||
}
|
||||
|
||||
const ctx = get2dContext(canvas);
|
||||
|
||||
let state: GameState | null = null;
|
||||
let totalRounds: number | null = null;
|
||||
let viewerCount = 0;
|
||||
let connected = false;
|
||||
let ws: WebSocket | null = null;
|
||||
let reconnectTimer: number | null = null;
|
||||
let lastMessageAt = 0;
|
||||
|
||||
function getColor(name: string): string {
|
||||
return MODEL_COLORS[name] ?? "#aeb6d6";
|
||||
}
|
||||
|
||||
function getLogoUrl(name: string): string | null {
|
||||
if (name.includes("Gemini")) return "/assets/logos/gemini.svg";
|
||||
if (name.includes("Kimi")) return "/assets/logos/kimi.svg";
|
||||
if (name.includes("DeepSeek")) return "/assets/logos/deepseek.svg";
|
||||
if (name.includes("GLM")) return "/assets/logos/glm.svg";
|
||||
if (name.includes("GPT")) return "/assets/logos/openai.svg";
|
||||
if (name.includes("Opus") || name.includes("Sonnet")) return "/assets/logos/claude.svg";
|
||||
if (name.includes("Grok")) return "/assets/logos/grok.svg";
|
||||
if (name.includes("MiniMax")) return "/assets/logos/minimax.svg";
|
||||
return null;
|
||||
}
|
||||
|
||||
const logoCache: Record<string, HTMLImageElement> = {};
|
||||
function drawModelLogo(name: string, x: number, y: number, size: number): boolean {
|
||||
const url = getLogoUrl(name);
|
||||
if (!url) return false;
|
||||
if (!logoCache[url]) {
|
||||
const img = new Image();
|
||||
img.src = url;
|
||||
logoCache[url] = img;
|
||||
}
|
||||
const img = logoCache[url];
|
||||
if (img.complete && img.naturalHeight !== 0) {
|
||||
ctx.drawImage(img, x, y, size, size);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function setupWebSocket() {
|
||||
const wsProtocol = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||
const wsUrl = `${wsProtocol}//${window.location.host}/ws`;
|
||||
ws = new WebSocket(wsUrl);
|
||||
|
||||
ws.onopen = () => {
|
||||
connected = true;
|
||||
setStatus("WS connected");
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
connected = false;
|
||||
setStatus("WS reconnecting...");
|
||||
if (reconnectTimer !== null) window.clearTimeout(reconnectTimer);
|
||||
reconnectTimer = window.setTimeout(setupWebSocket, 1_000);
|
||||
};
|
||||
|
||||
ws.onmessage = (e) => {
|
||||
try {
|
||||
const msg = JSON.parse(String(e.data)) as ServerMessage;
|
||||
if (msg.type === "state") {
|
||||
state = msg.data;
|
||||
totalRounds =
|
||||
Number.isFinite(msg.totalRounds) && msg.totalRounds >= 0
|
||||
? msg.totalRounds
|
||||
: null;
|
||||
viewerCount = msg.viewerCount;
|
||||
lastMessageAt = Date.now();
|
||||
}
|
||||
} catch {
|
||||
// Ignore malformed spectator payloads.
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function setStatus(value: string) {
|
||||
statusEl.textContent = value;
|
||||
}
|
||||
|
||||
function roundRect(
|
||||
x: number,
|
||||
y: number,
|
||||
w: number,
|
||||
h: number,
|
||||
r: number,
|
||||
fillStyle: string,
|
||||
) {
|
||||
const p = new Path2D();
|
||||
p.moveTo(x + r, y);
|
||||
p.lineTo(x + w - r, y);
|
||||
p.quadraticCurveTo(x + w, y, x + w, y + r);
|
||||
p.lineTo(x + w, y + h - r);
|
||||
p.quadraticCurveTo(x + w, y + h, x + w - r, y + h);
|
||||
p.lineTo(x + r, y + h);
|
||||
p.quadraticCurveTo(x, y + h, x, y + h - r);
|
||||
p.lineTo(x, y + r);
|
||||
p.quadraticCurveTo(x, y, x + r, y);
|
||||
ctx.fillStyle = fillStyle;
|
||||
ctx.fill(p);
|
||||
}
|
||||
|
||||
function textLines(
|
||||
text: string,
|
||||
maxWidth: number,
|
||||
font: string,
|
||||
maxLines = 3,
|
||||
): string[] {
|
||||
ctx.font = font;
|
||||
const words = text.split(/\s+/).filter(Boolean);
|
||||
const lines: string[] = [];
|
||||
let current = "";
|
||||
|
||||
for (const word of words) {
|
||||
const candidate = current ? `${current} ${word}` : word;
|
||||
if (ctx.measureText(candidate).width <= maxWidth) {
|
||||
current = candidate;
|
||||
continue;
|
||||
}
|
||||
if (current) lines.push(current);
|
||||
current = word;
|
||||
if (lines.length >= maxLines - 1) break;
|
||||
}
|
||||
|
||||
if (current) {
|
||||
lines.push(current);
|
||||
}
|
||||
|
||||
if (lines.length > maxLines) {
|
||||
lines.length = maxLines;
|
||||
}
|
||||
|
||||
if (words.length > 0 && lines.length === maxLines) {
|
||||
const last = lines[maxLines - 1] ?? "";
|
||||
if (ctx.measureText(last).width > maxWidth) {
|
||||
let trimmed = last;
|
||||
while (trimmed.length > 3 && ctx.measureText(`${trimmed}...`).width > maxWidth) {
|
||||
trimmed = trimmed.slice(0, -1);
|
||||
}
|
||||
lines[maxLines - 1] = `${trimmed}...`;
|
||||
}
|
||||
}
|
||||
|
||||
return lines;
|
||||
}
|
||||
|
||||
function drawTextBlock(
|
||||
text: string,
|
||||
x: number,
|
||||
y: number,
|
||||
maxWidth: number,
|
||||
lineHeight: number,
|
||||
font: string,
|
||||
color: string,
|
||||
maxLines: number,
|
||||
) {
|
||||
const lines = textLines(text, maxWidth, font, maxLines);
|
||||
ctx.font = font;
|
||||
ctx.fillStyle = color;
|
||||
lines.forEach((line, idx) => {
|
||||
ctx.fillText(line, x, y + idx * lineHeight);
|
||||
});
|
||||
}
|
||||
|
||||
function drawHeader() {
|
||||
ctx.fillStyle = "#0a0a0a";
|
||||
ctx.fillRect(0, 0, WIDTH, HEIGHT);
|
||||
|
||||
ctx.font = '700 32px "Inter", sans-serif';
|
||||
ctx.fillStyle = "#ededed";
|
||||
ctx.fillText("quipslop", 48, 72);
|
||||
|
||||
const viewersText = `${viewerCount} viewer${viewerCount === 1 ? "" : "s"} watching`;
|
||||
ctx.font = '600 14px "JetBrains Mono", monospace';
|
||||
ctx.fillStyle = "#888";
|
||||
const vWidth = ctx.measureText(viewersText).width;
|
||||
|
||||
const pillW = vWidth + 40;
|
||||
const pillX = WIDTH - 380 - 48 - pillW;
|
||||
roundRect(pillX, 44, pillW, 36, 18, "rgba(255,255,255,0.02)");
|
||||
|
||||
ctx.fillStyle = connected ? "#22c55e" : "#ef4444";
|
||||
ctx.beginPath();
|
||||
ctx.arc(pillX + 16, 62, 4, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
|
||||
ctx.fillStyle = "#888";
|
||||
ctx.fillText(viewersText, pillX + 28, 67);
|
||||
}
|
||||
|
||||
function drawScoreboard(scores: Record<string, number>) {
|
||||
const entries = Object.entries(scores).sort((a, b) => b[1] - a[1]);
|
||||
|
||||
roundRect(WIDTH - 380, 0, 380, HEIGHT, 0, "#111");
|
||||
ctx.fillStyle = "#1c1c1c";
|
||||
ctx.fillRect(WIDTH - 380, 0, 1, HEIGHT);
|
||||
|
||||
ctx.font = '700 14px "JetBrains Mono", monospace';
|
||||
ctx.fillStyle = "#888";
|
||||
ctx.fillText("STANDINGS", WIDTH - 340, 72);
|
||||
|
||||
const maxScore = entries[0]?.[1] || 1;
|
||||
|
||||
entries.slice(0, 10).forEach(([name, score], index) => {
|
||||
const y = 140 + index * 60;
|
||||
const color = getColor(name);
|
||||
const pct = maxScore > 0 ? (score / maxScore) : 0;
|
||||
|
||||
ctx.font = '600 16px "JetBrains Mono", monospace';
|
||||
ctx.fillStyle = "#888";
|
||||
const rank = index === 0 && score > 0 ? "👑" : String(index + 1);
|
||||
ctx.fillText(rank, WIDTH - 340, y + 20);
|
||||
|
||||
ctx.font = '600 16px "Inter", sans-serif';
|
||||
ctx.fillStyle = color;
|
||||
const nameText = name.length > 20 ? `${name.slice(0, 20)}...` : name;
|
||||
|
||||
const drewLogo = drawModelLogo(name, WIDTH - 300, y + 4, 20);
|
||||
if (drewLogo) {
|
||||
ctx.fillText(nameText, WIDTH - 300 + 28, y + 20);
|
||||
} else {
|
||||
ctx.fillText(nameText, WIDTH - 300, y + 20);
|
||||
}
|
||||
|
||||
roundRect(WIDTH - 300, y + 36, 200, 4, 2, "#1c1c1c");
|
||||
if (pct > 0) {
|
||||
roundRect(WIDTH - 300, y + 36, Math.max(8, 200 * pct), 4, 2, color);
|
||||
}
|
||||
|
||||
ctx.font = '700 16px "JetBrains Mono", monospace';
|
||||
ctx.fillStyle = "#888";
|
||||
const scoreText = String(score);
|
||||
const scoreWidth = ctx.measureText(scoreText).width;
|
||||
ctx.fillText(scoreText, WIDTH - 48 - scoreWidth, y + 20);
|
||||
});
|
||||
}
|
||||
|
||||
function drawRound(round: RoundState) {
|
||||
const mainW = WIDTH - 380;
|
||||
|
||||
const phaseLabel =
|
||||
(round.phase === "prompting"
|
||||
? "Writing prompt"
|
||||
: round.phase === "answering"
|
||||
? "Answering"
|
||||
: round.phase === "voting"
|
||||
? "Judges voting"
|
||||
: "Complete"
|
||||
).toUpperCase();
|
||||
|
||||
ctx.font = '700 16px "JetBrains Mono", monospace';
|
||||
ctx.fillStyle = "#ededed";
|
||||
const totalText = totalRounds !== null ? `/${totalRounds}` : "";
|
||||
ctx.fillText(`Round ${round.num}${totalText}`, 64, 150);
|
||||
|
||||
ctx.fillStyle = "#888";
|
||||
const labelWidth = ctx.measureText(phaseLabel).width;
|
||||
ctx.fillText(phaseLabel, mainW - 64 - labelWidth, 150);
|
||||
|
||||
ctx.font = '600 14px "JetBrains Mono", monospace';
|
||||
ctx.fillStyle = "#888";
|
||||
const promptedText = "PROMPTED BY ";
|
||||
ctx.fillText(promptedText, 64, 210);
|
||||
|
||||
const pTw = ctx.measureText(promptedText).width;
|
||||
ctx.fillStyle = getColor(round.prompter.name);
|
||||
const drewPLogo = drawModelLogo(round.prompter.name, 64 + pTw, 210 - 12, 16);
|
||||
|
||||
if (drewPLogo) {
|
||||
ctx.fillText(round.prompter.name.toUpperCase(), 64 + pTw + 20, 210);
|
||||
} else {
|
||||
ctx.fillText(round.prompter.name.toUpperCase(), 64 + pTw, 210);
|
||||
}
|
||||
|
||||
const promptText =
|
||||
round.prompt ??
|
||||
(round.phase === "prompting" ? "Generating prompt..." : "Prompt unavailable");
|
||||
|
||||
ctx.fillStyle = "#D97757";
|
||||
ctx.fillRect(64, 230, 4, Math.min(100, promptText.length > 100 ? 120 : 64));
|
||||
|
||||
drawTextBlock(
|
||||
promptText,
|
||||
92,
|
||||
260,
|
||||
mainW - 160,
|
||||
64,
|
||||
'400 48px "DM Serif Display", serif',
|
||||
round.prompt ? "#ededed" : "#444",
|
||||
2,
|
||||
);
|
||||
|
||||
if (round.phase !== "prompting") {
|
||||
const [taskA, taskB] = round.answerTasks;
|
||||
const cardW = (mainW - 160) / 2;
|
||||
drawContestantCard(taskA, 64, 400, cardW, 580, round);
|
||||
drawContestantCard(taskB, 64 + cardW + 32, 400, cardW, 580, round);
|
||||
}
|
||||
}
|
||||
|
||||
function drawContestantCard(
|
||||
task: TaskInfo,
|
||||
x: number,
|
||||
y: number,
|
||||
w: number,
|
||||
h: number,
|
||||
round: RoundState,
|
||||
) {
|
||||
const [a, b] = round.contestants;
|
||||
let votesA = 0;
|
||||
let votesB = 0;
|
||||
const taskVoters: VoteInfo[] = [];
|
||||
for (const vote of round.votes) {
|
||||
if (vote.votedFor?.name === a.name) votesA += 1;
|
||||
if (vote.votedFor?.name === b.name) votesB += 1;
|
||||
if (vote.votedFor?.name === task.model.name) taskVoters.push(vote);
|
||||
}
|
||||
const isFirst = round.answerTasks[0].model.name === task.model.name;
|
||||
const voteCount = isFirst ? votesA : votesB;
|
||||
const isWinner = round.phase === "done" && voteCount > (isFirst ? votesB : votesA);
|
||||
|
||||
const color = getColor(task.model.name);
|
||||
|
||||
ctx.fillStyle = color;
|
||||
ctx.fillRect(x, y, isWinner ? 6 : 4, h);
|
||||
|
||||
if (isWinner) {
|
||||
roundRect(x, y, w, h, 0, "rgba(255,255,255,0.03)");
|
||||
}
|
||||
|
||||
ctx.font = '700 24px "Inter", sans-serif';
|
||||
ctx.fillStyle = color;
|
||||
const drewCLogo = drawModelLogo(task.model.name, x + 24, y + 18, 24);
|
||||
if (drewCLogo) {
|
||||
ctx.fillText(task.model.name, x + 56, y + 40);
|
||||
} else {
|
||||
ctx.fillText(task.model.name, x + 24, y + 40);
|
||||
}
|
||||
|
||||
if (isWinner) {
|
||||
ctx.font = '700 12px "JetBrains Mono", monospace';
|
||||
ctx.fillStyle = "#0a0a0a";
|
||||
const winW = ctx.measureText("WIN").width;
|
||||
roundRect(x + w - 24 - winW - 16, y + 20, winW + 16, 24, 4, "#ededed");
|
||||
ctx.fillStyle = "#0a0a0a";
|
||||
ctx.fillText("WIN", x + w - 24 - winW - 8, y + 36);
|
||||
}
|
||||
|
||||
const answer =
|
||||
!task.finishedAt && !task.result
|
||||
? "Writing answer..."
|
||||
: task.error
|
||||
? task.error
|
||||
: task.result ?? "No answer";
|
||||
|
||||
drawTextBlock(
|
||||
task.result ? `"${answer}"` : answer,
|
||||
x + 24,
|
||||
y + 110,
|
||||
w - 48,
|
||||
44,
|
||||
'400 32px "DM Serif Display", serif',
|
||||
isWinner ? "#ededed" : (!task.finishedAt && !task.result ? "#444" : "#888"),
|
||||
6,
|
||||
);
|
||||
|
||||
const showVotes = round.phase === "voting" || round.phase === "done";
|
||||
if (showVotes) {
|
||||
const totalVotes = votesA + votesB;
|
||||
const pct = totalVotes > 0 ? Math.round((voteCount / totalVotes) * 100) : 0;
|
||||
|
||||
roundRect(x + 24, y + h - 60, w - 48, 4, 2, "#1c1c1c");
|
||||
if (pct > 0) {
|
||||
roundRect(x + 24, y + h - 60, Math.max(8, ((w - 48) * pct) / 100), 4, 2, color);
|
||||
}
|
||||
|
||||
ctx.font = '700 20px "JetBrains Mono", monospace';
|
||||
ctx.fillStyle = color;
|
||||
ctx.fillText(String(voteCount), x + 24, y + h - 24);
|
||||
|
||||
ctx.font = '600 14px "JetBrains Mono", monospace';
|
||||
ctx.fillStyle = "#444";
|
||||
const vTxt = `vote${voteCount === 1 ? "" : "s"}`;
|
||||
const vCountW = ctx.measureText(String(voteCount)).width;
|
||||
const vTxtW = ctx.measureText(vTxt).width;
|
||||
ctx.fillText(vTxt, x + 24 + vCountW + 8, y + h - 25);
|
||||
|
||||
let avatarX = x + 24 + vCountW + 8 + vTxtW + 16;
|
||||
const avatarY = y + h - 42;
|
||||
const avatarSize = 24;
|
||||
|
||||
for (const v of taskVoters) {
|
||||
const vColor = getColor(v.voter.name);
|
||||
const drewLogo = drawModelLogo(v.voter.name, avatarX, avatarY, avatarSize);
|
||||
|
||||
if (!drewLogo) {
|
||||
ctx.beginPath();
|
||||
ctx.arc(avatarX + avatarSize / 2, avatarY + avatarSize / 2, avatarSize / 2, 0, Math.PI * 2);
|
||||
ctx.fillStyle = vColor;
|
||||
ctx.fill();
|
||||
ctx.font = '700 12px "Inter", sans-serif';
|
||||
ctx.fillStyle = "#0a0a0a";
|
||||
const initial = v.voter.name[0] ?? "?";
|
||||
const tw = ctx.measureText(initial).width;
|
||||
ctx.fillText(initial, avatarX + avatarSize / 2 - tw / 2, avatarY + avatarSize / 2 + 4);
|
||||
}
|
||||
|
||||
avatarX += avatarSize + 8;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function drawFooter() {
|
||||
ctx.font = '600 12px "JetBrains Mono", monospace';
|
||||
ctx.fillStyle = "#444";
|
||||
const ageMs = Date.now() - lastMessageAt;
|
||||
const freshness =
|
||||
lastMessageAt === 0 ? "waiting for state" : `${Math.floor(ageMs / 1000)}s old`;
|
||||
ctx.fillText(`viewers:${viewerCount} updates:${freshness}`, 24, HEIGHT - 24);
|
||||
}
|
||||
|
||||
function drawWaiting() {
|
||||
const mainW = WIDTH - 380;
|
||||
ctx.font = '400 48px "DM Serif Display", serif';
|
||||
ctx.fillStyle = "#888";
|
||||
const text = "Waiting for game state...";
|
||||
const tw = ctx.measureText(text).width;
|
||||
ctx.fillText(text, (mainW - tw) / 2, HEIGHT / 2);
|
||||
}
|
||||
|
||||
function drawDone(scores: Record<string, number>) {
|
||||
const mainW = WIDTH - 380;
|
||||
const winner = Object.entries(scores).sort((a, b) => b[1] - a[1])[0];
|
||||
if (!winner) return;
|
||||
const [name, points] = winner;
|
||||
|
||||
ctx.font = '700 20px "JetBrains Mono", monospace';
|
||||
ctx.fillStyle = "#444";
|
||||
const go = "GAME OVER";
|
||||
const gow = ctx.measureText(go).width;
|
||||
ctx.fillText(go, (mainW - gow) / 2, HEIGHT / 2 - 100);
|
||||
|
||||
ctx.font = '400 80px "DM Serif Display", serif';
|
||||
ctx.fillStyle = getColor(name);
|
||||
const nw = ctx.measureText(name).width;
|
||||
ctx.fillText(name, (mainW - nw) / 2, HEIGHT / 2);
|
||||
|
||||
ctx.font = '600 24px "Inter", sans-serif';
|
||||
ctx.fillStyle = "#888";
|
||||
const wins = `is the funniest AI`;
|
||||
const ww = ctx.measureText(wins).width;
|
||||
ctx.fillText(wins, (mainW - ww) / 2, HEIGHT / 2 + 60);
|
||||
}
|
||||
|
||||
function draw() {
|
||||
drawHeader();
|
||||
if (!state) {
|
||||
drawWaiting();
|
||||
drawFooter();
|
||||
return;
|
||||
}
|
||||
|
||||
drawScoreboard(state.scores);
|
||||
|
||||
const lastCompleted = state.completed[state.completed.length - 1];
|
||||
const isNextPrompting = state.active?.phase === "prompting" && !state.active.prompt;
|
||||
const displayRound = isNextPrompting && lastCompleted ? lastCompleted : state.active;
|
||||
|
||||
if (state.done) {
|
||||
drawDone(state.scores);
|
||||
} else if (displayRound) {
|
||||
drawRound(displayRound);
|
||||
} else {
|
||||
drawWaiting();
|
||||
}
|
||||
drawFooter();
|
||||
}
|
||||
|
||||
function renderLoop() {
|
||||
draw();
|
||||
window.requestAnimationFrame(renderLoop);
|
||||
}
|
||||
|
||||
function startCanvasCaptureSink() {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const sink = params.get("sink");
|
||||
if (!sink) return;
|
||||
|
||||
if (!("MediaRecorder" in window)) {
|
||||
setStatus("MediaRecorder unavailable");
|
||||
return;
|
||||
}
|
||||
|
||||
const fps = Number.parseInt(params.get("captureFps") ?? "30", 10);
|
||||
const bitRate = Number.parseInt(params.get("captureBitrate") ?? "12000000", 10);
|
||||
const stream = canvas.captureStream(Number.isFinite(fps) && fps > 0 ? fps : 30);
|
||||
const socket = new WebSocket(sink);
|
||||
socket.binaryType = "arraybuffer";
|
||||
|
||||
let recorder: MediaRecorder | null = null;
|
||||
const mimeCandidates = [
|
||||
"video/webm;codecs=vp8",
|
||||
"video/webm;codecs=vp9",
|
||||
"video/webm",
|
||||
];
|
||||
const mimeType =
|
||||
mimeCandidates.find((value) => MediaRecorder.isTypeSupported(value)) ?? "";
|
||||
|
||||
socket.onopen = () => {
|
||||
const options: MediaRecorderOptions = {
|
||||
videoBitsPerSecond: Number.isFinite(bitRate) && bitRate > 0 ? bitRate : 12_000_000,
|
||||
};
|
||||
if (mimeType) options.mimeType = mimeType;
|
||||
|
||||
recorder = new MediaRecorder(stream, options);
|
||||
recorder.ondataavailable = async (event) => {
|
||||
if (event.data.size === 0) return;
|
||||
if (socket.readyState !== WebSocket.OPEN) return;
|
||||
if (socket.bufferedAmount > 16_000_000) return;
|
||||
const chunk = await event.data.arrayBuffer();
|
||||
socket.send(chunk);
|
||||
};
|
||||
recorder.onerror = () => {
|
||||
setStatus("Recorder error");
|
||||
};
|
||||
recorder.start(250);
|
||||
setStatus(`capture->ws ${fps}fps`);
|
||||
};
|
||||
|
||||
socket.onclose = () => {
|
||||
recorder?.stop();
|
||||
setStatus("capture sink closed");
|
||||
};
|
||||
}
|
||||
|
||||
setupWebSocket();
|
||||
startCanvasCaptureSink();
|
||||
renderLoop();
|
||||
203
bun.lock
203
bun.lock
@@ -8,6 +8,7 @@
|
||||
"@openrouter/ai-sdk-provider": "^2.2.3",
|
||||
"ai": "^6.0.94",
|
||||
"ink": "^6.8.0",
|
||||
"puppeteer": "^24.2.0",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
},
|
||||
@@ -30,12 +31,20 @@
|
||||
|
||||
"@alcalzone/ansi-tokenize": ["@alcalzone/ansi-tokenize@0.2.5", "", { "dependencies": { "ansi-styles": "^6.2.1", "is-fullwidth-code-point": "^5.0.0" } }, "sha512-3NX/MpTdroi0aKz134A6RC2Gb2iXVECN4QaAXnvCIxxIm3C3AVB1mkUe8NaaiyvOpDfsrqWhYtj+Q6a62RrTsw=="],
|
||||
|
||||
"@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="],
|
||||
|
||||
"@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="],
|
||||
|
||||
"@openrouter/ai-sdk-provider": ["@openrouter/ai-sdk-provider@2.2.3", "", { "peerDependencies": { "ai": "^6.0.0", "zod": "^3.25.0 || ^4.0.0" } }, "sha512-NovC+BaCfEeJwhToDrs8JeDYXXlJdEyz7lcxkjtyePSE4eoAKik872SyDK0MzXKcz8MRkv7XlNhPI6zz4TQp0g=="],
|
||||
|
||||
"@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="],
|
||||
|
||||
"@puppeteer/browsers": ["@puppeteer/browsers@2.13.0", "", { "dependencies": { "debug": "^4.4.3", "extract-zip": "^2.0.1", "progress": "^2.0.3", "proxy-agent": "^6.5.0", "semver": "^7.7.4", "tar-fs": "^3.1.1", "yargs": "^17.7.2" }, "bin": { "browsers": "lib/cjs/main-cli.js" } }, "sha512-46BZJYJjc/WwmKjsvDFykHtXrtomsCIrwYQPOP7VfMJoZY2bsDF9oROBABR3paDjDcmkUye1Pb1BqdcdiipaWA=="],
|
||||
|
||||
"@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
|
||||
|
||||
"@tootallnate/quickjs-emscripten": ["@tootallnate/quickjs-emscripten@0.23.0", "", {}, "sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA=="],
|
||||
|
||||
"@types/bun": ["@types/bun@1.3.9", "", { "dependencies": { "bun-types": "1.3.9" } }, "sha512-KQ571yULOdWJiMH+RIWIOZ7B2RXQGpL1YQrBtLIV3FqDcCu6FsbFUBwhdKUlCKUpS3PJDsHlJ1QKlpxoVR+xtw=="],
|
||||
|
||||
"@types/node": ["@types/node@25.3.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-4K3bqJpXpqfg2XKGK9bpDTc6xO/xoUP/RBWS7AtRMug6zZFaRekiLzjVtAoZMquxoAbzBvy5nxQ7veS5eYzf8A=="],
|
||||
@@ -44,8 +53,12 @@
|
||||
|
||||
"@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="],
|
||||
|
||||
"@types/yauzl": ["@types/yauzl@2.10.3", "", { "dependencies": { "@types/node": "*" } }, "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q=="],
|
||||
|
||||
"@vercel/oidc": ["@vercel/oidc@3.1.0", "", {}, "sha512-Fw28YZpRnA3cAHHDlkt7xQHiJ0fcL+NRcIqsocZQUSmbzeIKRpwttJjik5ZGanXP+vlA4SbTg+AbA3bP363l+w=="],
|
||||
|
||||
"agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="],
|
||||
|
||||
"ai": ["ai@6.0.94", "", { "dependencies": { "@ai-sdk/gateway": "3.0.52", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.15", "@opentelemetry/api": "1.9.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-/F9wh262HbK05b/5vILh38JvPiheonT+kBj1L97712E7VPchqmcx7aJuZN3QSk5Pj6knxUJLm2FFpYJI1pHXUA=="],
|
||||
|
||||
"ansi-escapes": ["ansi-escapes@7.3.0", "", { "dependencies": { "environment": "^1.0.0" } }, "sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg=="],
|
||||
@@ -54,94 +67,284 @@
|
||||
|
||||
"ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="],
|
||||
|
||||
"argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
|
||||
|
||||
"ast-types": ["ast-types@0.13.4", "", { "dependencies": { "tslib": "^2.0.1" } }, "sha512-x1FCFnFifvYDDzTaLII71vG5uvDwgtmDTEVWAxrgeiR8VjMONcCXJx7E+USjDtHlwFmt9MysbqgF9b9Vjr6w+w=="],
|
||||
|
||||
"auto-bind": ["auto-bind@5.0.1", "", {}, "sha512-ooviqdwwgfIfNmDwo94wlshcdzfO64XV0Cg6oDsDYBJfITDz1EngD2z7DkbvCWn+XIMsIqW27sEVF6qcpJrRcg=="],
|
||||
|
||||
"b4a": ["b4a@1.8.0", "", { "peerDependencies": { "react-native-b4a": "*" }, "optionalPeers": ["react-native-b4a"] }, "sha512-qRuSmNSkGQaHwNbM7J78Wwy+ghLEYF1zNrSeMxj4Kgw6y33O3mXcQ6Ie9fRvfU/YnxWkOchPXbaLb73TkIsfdg=="],
|
||||
|
||||
"bare-events": ["bare-events@2.8.2", "", { "peerDependencies": { "bare-abort-controller": "*" }, "optionalPeers": ["bare-abort-controller"] }, "sha512-riJjyv1/mHLIPX4RwiK+oW9/4c3TEUeORHKefKAKnZ5kyslbN+HXowtbaVEqt4IMUB7OXlfixcs6gsFeo/jhiQ=="],
|
||||
|
||||
"bare-fs": ["bare-fs@4.5.4", "", { "dependencies": { "bare-events": "^2.5.4", "bare-path": "^3.0.0", "bare-stream": "^2.6.4", "bare-url": "^2.2.2", "fast-fifo": "^1.3.2" }, "peerDependencies": { "bare-buffer": "*" }, "optionalPeers": ["bare-buffer"] }, "sha512-POK4oplfA7P7gqvetNmCs4CNtm9fNsx+IAh7jH7GgU0OJdge2rso0R20TNWVq6VoWcCvsTdlNDaleLHGaKx8CA=="],
|
||||
|
||||
"bare-os": ["bare-os@3.6.2", "", {}, "sha512-T+V1+1srU2qYNBmJCXZkUY5vQ0B4FSlL3QDROnKQYOqeiQR8UbjNHlPa+TIbM4cuidiN9GaTaOZgSEgsvPbh5A=="],
|
||||
|
||||
"bare-path": ["bare-path@3.0.0", "", { "dependencies": { "bare-os": "^3.0.1" } }, "sha512-tyfW2cQcB5NN8Saijrhqn0Zh7AnFNsnczRcuWODH0eYAXBsJ5gVxAUuNr7tsHSC6IZ77cA0SitzT+s47kot8Mw=="],
|
||||
|
||||
"bare-stream": ["bare-stream@2.8.0", "", { "dependencies": { "streamx": "^2.21.0", "teex": "^1.0.1" }, "peerDependencies": { "bare-buffer": "*", "bare-events": "*" }, "optionalPeers": ["bare-buffer", "bare-events"] }, "sha512-reUN0M2sHRqCdG4lUK3Fw8w98eeUIZHL5c3H7Mbhk2yVBL+oofgaIp0ieLfD5QXwPCypBpmEEKU2WZKzbAk8GA=="],
|
||||
|
||||
"bare-url": ["bare-url@2.3.2", "", { "dependencies": { "bare-path": "^3.0.0" } }, "sha512-ZMq4gd9ngV5aTMa5p9+UfY0b3skwhHELaDkhEHetMdX0LRkW9kzaym4oo/Eh+Ghm0CCDuMTsRIGM/ytUc1ZYmw=="],
|
||||
|
||||
"basic-ftp": ["basic-ftp@5.1.0", "", {}, "sha512-RkaJzeJKDbaDWTIPiJwubyljaEPwpVWkm9Rt5h9Nd6h7tEXTJ3VB4qxdZBioV7JO5yLUaOKwz7vDOzlncUsegw=="],
|
||||
|
||||
"buffer-crc32": ["buffer-crc32@0.2.13", "", {}, "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ=="],
|
||||
|
||||
"bun-types": ["bun-types@1.3.9", "", { "dependencies": { "@types/node": "*" } }, "sha512-+UBWWOakIP4Tswh0Bt0QD0alpTY8cb5hvgiYeWCMet9YukHbzuruIEeXC2D7nMJPB12kbh8C7XJykSexEqGKJg=="],
|
||||
|
||||
"callsites": ["callsites@3.1.0", "", {}, "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="],
|
||||
|
||||
"chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="],
|
||||
|
||||
"chromium-bidi": ["chromium-bidi@14.0.0", "", { "dependencies": { "mitt": "^3.0.1", "zod": "^3.24.1" }, "peerDependencies": { "devtools-protocol": "*" } }, "sha512-9gYlLtS6tStdRWzrtXaTMnqcM4dudNegMXJxkR0I/CXObHalYeYcAMPrL19eroNZHtJ8DQmu1E+ZNOYu/IXMXw=="],
|
||||
|
||||
"cli-boxes": ["cli-boxes@3.0.0", "", {}, "sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g=="],
|
||||
|
||||
"cli-cursor": ["cli-cursor@4.0.0", "", { "dependencies": { "restore-cursor": "^4.0.0" } }, "sha512-VGtlMu3x/4DOtIUwEkRezxUZ2lBacNJCHash0N0WeZDBS+7Ux1dm3XWAgWYxLJFMMdOeXMHXorshEFhbMSGelg=="],
|
||||
|
||||
"cli-truncate": ["cli-truncate@5.1.1", "", { "dependencies": { "slice-ansi": "^7.1.0", "string-width": "^8.0.0" } }, "sha512-SroPvNHxUnk+vIW/dOSfNqdy1sPEFkrTk6TUtqLCnBlo3N7TNYYkzzN7uSD6+jVjrdO4+p8nH7JzH6cIvUem6A=="],
|
||||
|
||||
"cliui": ["cliui@8.0.1", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", "wrap-ansi": "^7.0.0" } }, "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ=="],
|
||||
|
||||
"code-excerpt": ["code-excerpt@4.0.0", "", { "dependencies": { "convert-to-spaces": "^2.0.1" } }, "sha512-xxodCmBen3iy2i0WtAK8FlFNrRzjUqjRsMfho58xT/wvZU1YTM3fCnRjcy1gJPMepaRlgm/0e6w8SpWHpn3/cA=="],
|
||||
|
||||
"color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
|
||||
|
||||
"color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
|
||||
|
||||
"convert-to-spaces": ["convert-to-spaces@2.0.1", "", {}, "sha512-rcQ1bsQO9799wq24uE5AM2tAILy4gXGIK/njFWcVQkGNZ96edlpY+A7bjwvzjYvLDyzmG1MmMLZhpcsb+klNMQ=="],
|
||||
|
||||
"cosmiconfig": ["cosmiconfig@9.0.0", "", { "dependencies": { "env-paths": "^2.2.1", "import-fresh": "^3.3.0", "js-yaml": "^4.1.0", "parse-json": "^5.2.0" }, "peerDependencies": { "typescript": ">=4.9.5" }, "optionalPeers": ["typescript"] }, "sha512-itvL5h8RETACmOTFc4UfIyB2RfEHi71Ax6E/PivVxq9NseKbOWpeyHEOIbmAw1rs8Ak0VursQNww7lf7YtUwzg=="],
|
||||
|
||||
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
|
||||
|
||||
"data-uri-to-buffer": ["data-uri-to-buffer@6.0.2", "", {}, "sha512-7hvf7/GW8e86rW0ptuwS3OcBGDjIi6SZva7hCyWC0yYry2cOPmLIjXAUHI6DK2HsnwJd9ifmt57i8eV2n4YNpw=="],
|
||||
|
||||
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
|
||||
|
||||
"degenerator": ["degenerator@5.0.1", "", { "dependencies": { "ast-types": "^0.13.4", "escodegen": "^2.1.0", "esprima": "^4.0.1" } }, "sha512-TllpMR/t0M5sqCXfj85i4XaAzxmS5tVA16dqvdkMwGmzI+dXLXnw3J+3Vdv7VKw+ThlTMboK6i9rnZ6Nntj5CQ=="],
|
||||
|
||||
"devtools-protocol": ["devtools-protocol@0.0.1566079", "", {}, "sha512-MJfAEA1UfVhSs7fbSQOG4czavUp1ajfg6prlAN0+cmfa2zNjaIbvq8VneP7do1WAQQIvgNJWSMeP6UyI90gIlQ=="],
|
||||
|
||||
"emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="],
|
||||
|
||||
"end-of-stream": ["end-of-stream@1.4.5", "", { "dependencies": { "once": "^1.4.0" } }, "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg=="],
|
||||
|
||||
"env-paths": ["env-paths@2.2.1", "", {}, "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A=="],
|
||||
|
||||
"environment": ["environment@1.1.0", "", {}, "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q=="],
|
||||
|
||||
"error-ex": ["error-ex@1.3.4", "", { "dependencies": { "is-arrayish": "^0.2.1" } }, "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ=="],
|
||||
|
||||
"es-toolkit": ["es-toolkit@1.44.0", "", {}, "sha512-6penXeZalaV88MM3cGkFZZfOoLGWshWWfdy0tWw/RlVVyhvMaWSBTOvXNeiW3e5FwdS5ePW0LGEu17zT139ktg=="],
|
||||
|
||||
"escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
|
||||
|
||||
"escape-string-regexp": ["escape-string-regexp@2.0.0", "", {}, "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w=="],
|
||||
|
||||
"escodegen": ["escodegen@2.1.0", "", { "dependencies": { "esprima": "^4.0.1", "estraverse": "^5.2.0", "esutils": "^2.0.2" }, "optionalDependencies": { "source-map": "~0.6.1" }, "bin": { "esgenerate": "bin/esgenerate.js", "escodegen": "bin/escodegen.js" } }, "sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w=="],
|
||||
|
||||
"esprima": ["esprima@4.0.1", "", { "bin": { "esparse": "./bin/esparse.js", "esvalidate": "./bin/esvalidate.js" } }, "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A=="],
|
||||
|
||||
"estraverse": ["estraverse@5.3.0", "", {}, "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA=="],
|
||||
|
||||
"esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="],
|
||||
|
||||
"events-universal": ["events-universal@1.0.1", "", { "dependencies": { "bare-events": "^2.7.0" } }, "sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw=="],
|
||||
|
||||
"eventsource-parser": ["eventsource-parser@3.0.6", "", {}, "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg=="],
|
||||
|
||||
"extract-zip": ["extract-zip@2.0.1", "", { "dependencies": { "debug": "^4.1.1", "get-stream": "^5.1.0", "yauzl": "^2.10.0" }, "optionalDependencies": { "@types/yauzl": "^2.9.1" }, "bin": { "extract-zip": "cli.js" } }, "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg=="],
|
||||
|
||||
"fast-fifo": ["fast-fifo@1.3.2", "", {}, "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ=="],
|
||||
|
||||
"fd-slicer": ["fd-slicer@1.1.0", "", { "dependencies": { "pend": "~1.2.0" } }, "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g=="],
|
||||
|
||||
"get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="],
|
||||
|
||||
"get-east-asian-width": ["get-east-asian-width@1.5.0", "", {}, "sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA=="],
|
||||
|
||||
"get-stream": ["get-stream@5.2.0", "", { "dependencies": { "pump": "^3.0.0" } }, "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA=="],
|
||||
|
||||
"get-uri": ["get-uri@6.0.5", "", { "dependencies": { "basic-ftp": "^5.0.2", "data-uri-to-buffer": "^6.0.2", "debug": "^4.3.4" } }, "sha512-b1O07XYq8eRuVzBNgJLstU6FYc1tS6wnMtF1I1D9lE8LxZSOGZ7LhxN54yPP6mGw5f2CkXY2BQUL9Fx41qvcIg=="],
|
||||
|
||||
"http-proxy-agent": ["http-proxy-agent@7.0.2", "", { "dependencies": { "agent-base": "^7.1.0", "debug": "^4.3.4" } }, "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig=="],
|
||||
|
||||
"https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="],
|
||||
|
||||
"import-fresh": ["import-fresh@3.3.1", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="],
|
||||
|
||||
"indent-string": ["indent-string@5.0.0", "", {}, "sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg=="],
|
||||
|
||||
"ink": ["ink@6.8.0", "", { "dependencies": { "@alcalzone/ansi-tokenize": "^0.2.4", "ansi-escapes": "^7.3.0", "ansi-styles": "^6.2.1", "auto-bind": "^5.0.1", "chalk": "^5.6.0", "cli-boxes": "^3.0.0", "cli-cursor": "^4.0.0", "cli-truncate": "^5.1.1", "code-excerpt": "^4.0.0", "es-toolkit": "^1.39.10", "indent-string": "^5.0.0", "is-in-ci": "^2.0.0", "patch-console": "^2.0.0", "react-reconciler": "^0.33.0", "scheduler": "^0.27.0", "signal-exit": "^3.0.7", "slice-ansi": "^8.0.0", "stack-utils": "^2.0.6", "string-width": "^8.1.1", "terminal-size": "^4.0.1", "type-fest": "^5.4.1", "widest-line": "^6.0.0", "wrap-ansi": "^9.0.0", "ws": "^8.18.0", "yoga-layout": "~3.2.1" }, "peerDependencies": { "@types/react": ">=19.0.0", "react": ">=19.0.0", "react-devtools-core": ">=6.1.2" }, "optionalPeers": ["@types/react", "react-devtools-core"] }, "sha512-sbl1RdLOgkO9isK42WCZlJCFN9hb++sX9dsklOvfd1YQ3bQ2AiFu12Q6tFlr0HvEUvzraJntQCCpfEoUe9DSzA=="],
|
||||
|
||||
"ip-address": ["ip-address@10.1.0", "", {}, "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q=="],
|
||||
|
||||
"is-arrayish": ["is-arrayish@0.2.1", "", {}, "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg=="],
|
||||
|
||||
"is-fullwidth-code-point": ["is-fullwidth-code-point@5.1.0", "", { "dependencies": { "get-east-asian-width": "^1.3.1" } }, "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ=="],
|
||||
|
||||
"is-in-ci": ["is-in-ci@2.0.0", "", { "bin": { "is-in-ci": "cli.js" } }, "sha512-cFeerHriAnhrQSbpAxL37W1wcJKUUX07HyLWZCW1URJT/ra3GyUTzBgUnh24TMVfNTV2Hij2HLxkPHFZfOZy5w=="],
|
||||
|
||||
"js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
|
||||
|
||||
"js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="],
|
||||
|
||||
"json-parse-even-better-errors": ["json-parse-even-better-errors@2.3.1", "", {}, "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w=="],
|
||||
|
||||
"json-schema": ["json-schema@0.4.0", "", {}, "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA=="],
|
||||
|
||||
"lines-and-columns": ["lines-and-columns@1.2.4", "", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="],
|
||||
|
||||
"lru-cache": ["lru-cache@7.18.3", "", {}, "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA=="],
|
||||
|
||||
"mimic-fn": ["mimic-fn@2.1.0", "", {}, "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg=="],
|
||||
|
||||
"mitt": ["mitt@3.0.1", "", {}, "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw=="],
|
||||
|
||||
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
||||
|
||||
"netmask": ["netmask@2.0.2", "", {}, "sha512-dBpDMdxv9Irdq66304OLfEmQ9tbNRFnFTuZiLo+bD+r332bBmMJ8GBLXklIXXgxd3+v9+KUnZaUR5PJMa75Gsg=="],
|
||||
|
||||
"once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="],
|
||||
|
||||
"onetime": ["onetime@5.1.2", "", { "dependencies": { "mimic-fn": "^2.1.0" } }, "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg=="],
|
||||
|
||||
"pac-proxy-agent": ["pac-proxy-agent@7.2.0", "", { "dependencies": { "@tootallnate/quickjs-emscripten": "^0.23.0", "agent-base": "^7.1.2", "debug": "^4.3.4", "get-uri": "^6.0.1", "http-proxy-agent": "^7.0.0", "https-proxy-agent": "^7.0.6", "pac-resolver": "^7.0.1", "socks-proxy-agent": "^8.0.5" } }, "sha512-TEB8ESquiLMc0lV8vcd5Ql/JAKAoyzHFXaStwjkzpOpC5Yv+pIzLfHvjTSdf3vpa2bMiUQrg9i6276yn8666aA=="],
|
||||
|
||||
"pac-resolver": ["pac-resolver@7.0.1", "", { "dependencies": { "degenerator": "^5.0.0", "netmask": "^2.0.2" } }, "sha512-5NPgf87AT2STgwa2ntRMr45jTKrYBGkVU36yT0ig/n/GMAa3oPqhZfIQ2kMEimReg0+t9kZViDVZ83qfVUlckg=="],
|
||||
|
||||
"parent-module": ["parent-module@1.0.1", "", { "dependencies": { "callsites": "^3.0.0" } }, "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g=="],
|
||||
|
||||
"parse-json": ["parse-json@5.2.0", "", { "dependencies": { "@babel/code-frame": "^7.0.0", "error-ex": "^1.3.1", "json-parse-even-better-errors": "^2.3.0", "lines-and-columns": "^1.1.6" } }, "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg=="],
|
||||
|
||||
"patch-console": ["patch-console@2.0.0", "", {}, "sha512-0YNdUceMdaQwoKce1gatDScmMo5pu/tfABfnzEqeG0gtTmd7mh/WcwgUjtAeOU7N8nFFlbQBnFK2gXW5fGvmMA=="],
|
||||
|
||||
"pend": ["pend@1.2.0", "", {}, "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg=="],
|
||||
|
||||
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
|
||||
|
||||
"progress": ["progress@2.0.3", "", {}, "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA=="],
|
||||
|
||||
"proxy-agent": ["proxy-agent@6.5.0", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "^4.3.4", "http-proxy-agent": "^7.0.1", "https-proxy-agent": "^7.0.6", "lru-cache": "^7.14.1", "pac-proxy-agent": "^7.1.0", "proxy-from-env": "^1.1.0", "socks-proxy-agent": "^8.0.5" } }, "sha512-TmatMXdr2KlRiA2CyDu8GqR8EjahTG3aY3nXjdzFyoZbmB8hrBsTyMezhULIXKnC0jpfjlmiZ3+EaCzoInSu/A=="],
|
||||
|
||||
"proxy-from-env": ["proxy-from-env@1.1.0", "", {}, "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="],
|
||||
|
||||
"pump": ["pump@3.0.3", "", { "dependencies": { "end-of-stream": "^1.1.0", "once": "^1.3.1" } }, "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA=="],
|
||||
|
||||
"puppeteer": ["puppeteer@24.37.5", "", { "dependencies": { "@puppeteer/browsers": "2.13.0", "chromium-bidi": "14.0.0", "cosmiconfig": "^9.0.0", "devtools-protocol": "0.0.1566079", "puppeteer-core": "24.37.5", "typed-query-selector": "^2.12.0" }, "bin": { "puppeteer": "lib/cjs/puppeteer/node/cli.js" } }, "sha512-3PAOIQLceyEmn1Fi76GkGO2EVxztv5OtdlB1m8hMUZL3f8KDHnlvXbvCXv+Ls7KzF1R0KdKBqLuT/Hhrok12hQ=="],
|
||||
|
||||
"puppeteer-core": ["puppeteer-core@24.37.5", "", { "dependencies": { "@puppeteer/browsers": "2.13.0", "chromium-bidi": "14.0.0", "debug": "^4.4.3", "devtools-protocol": "0.0.1566079", "typed-query-selector": "^2.12.0", "webdriver-bidi-protocol": "0.4.1", "ws": "^8.19.0" } }, "sha512-ybL7iE78YPN4T6J+sPLO7r0lSByp/0NN6PvfBEql219cOnttoTFzCWKiBOjstXSqi/OKpwae623DWAsL7cn2MQ=="],
|
||||
|
||||
"react": ["react@19.2.4", "", {}, "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ=="],
|
||||
|
||||
"react-dom": ["react-dom@19.2.4", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.4" } }, "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ=="],
|
||||
|
||||
"react-reconciler": ["react-reconciler@0.33.0", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.0" } }, "sha512-KetWRytFv1epdpJc3J4G75I4WrplZE5jOL7Yq0p34+OVOKF4Se7WrdIdVC45XsSSmUTlht2FM/fM1FZb1mfQeA=="],
|
||||
|
||||
"require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="],
|
||||
|
||||
"resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="],
|
||||
|
||||
"restore-cursor": ["restore-cursor@4.0.0", "", { "dependencies": { "onetime": "^5.1.0", "signal-exit": "^3.0.2" } }, "sha512-I9fPXU9geO9bHOt9pHHOhOkYerIMsmVaWB0rA2AI9ERh/+x/i7MV5HKBNrg+ljO5eoPVgCcnFuRjJ9uH6I/3eg=="],
|
||||
|
||||
"scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
|
||||
|
||||
"semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
|
||||
|
||||
"signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="],
|
||||
|
||||
"slice-ansi": ["slice-ansi@8.0.0", "", { "dependencies": { "ansi-styles": "^6.2.3", "is-fullwidth-code-point": "^5.1.0" } }, "sha512-stxByr12oeeOyY2BlviTNQlYV5xOj47GirPr4yA1hE9JCtxfQN0+tVbkxwCtYDQWhEKWFHsEK48ORg5jrouCAg=="],
|
||||
|
||||
"smart-buffer": ["smart-buffer@4.2.0", "", {}, "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg=="],
|
||||
|
||||
"socks": ["socks@2.8.7", "", { "dependencies": { "ip-address": "^10.0.1", "smart-buffer": "^4.2.0" } }, "sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A=="],
|
||||
|
||||
"socks-proxy-agent": ["socks-proxy-agent@8.0.5", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "^4.3.4", "socks": "^2.8.3" } }, "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw=="],
|
||||
|
||||
"source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="],
|
||||
|
||||
"stack-utils": ["stack-utils@2.0.6", "", { "dependencies": { "escape-string-regexp": "^2.0.0" } }, "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ=="],
|
||||
|
||||
"streamx": ["streamx@2.23.0", "", { "dependencies": { "events-universal": "^1.0.0", "fast-fifo": "^1.3.2", "text-decoder": "^1.1.0" } }, "sha512-kn+e44esVfn2Fa/O0CPFcex27fjIL6MkVae0Mm6q+E6f0hWv578YCERbv+4m02cjxvDsPKLnmxral/rR6lBMAg=="],
|
||||
|
||||
"string-width": ["string-width@8.2.0", "", { "dependencies": { "get-east-asian-width": "^1.5.0", "strip-ansi": "^7.1.2" } }, "sha512-6hJPQ8N0V0P3SNmP6h2J99RLuzrWz2gvT7VnK5tKvrNqJoyS9W4/Fb8mo31UiPvy00z7DQXkP2hnKBVav76thw=="],
|
||||
|
||||
"strip-ansi": ["strip-ansi@7.1.2", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA=="],
|
||||
|
||||
"tagged-tag": ["tagged-tag@1.0.0", "", {}, "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng=="],
|
||||
|
||||
"tar-fs": ["tar-fs@3.1.1", "", { "dependencies": { "pump": "^3.0.0", "tar-stream": "^3.1.5" }, "optionalDependencies": { "bare-fs": "^4.0.1", "bare-path": "^3.0.0" } }, "sha512-LZA0oaPOc2fVo82Txf3gw+AkEd38szODlptMYejQUhndHMLQ9M059uXR+AfS7DNo0NpINvSqDsvyaCrBVkptWg=="],
|
||||
|
||||
"tar-stream": ["tar-stream@3.1.7", "", { "dependencies": { "b4a": "^1.6.4", "fast-fifo": "^1.2.0", "streamx": "^2.15.0" } }, "sha512-qJj60CXt7IU1Ffyc3NJMjh6EkuCFej46zUqJ4J7pqYlThyd9bO0XBTmcOIhSzZJVWfsLks0+nle/j538YAW9RQ=="],
|
||||
|
||||
"teex": ["teex@1.0.1", "", { "dependencies": { "streamx": "^2.12.5" } }, "sha512-eYE6iEI62Ni1H8oIa7KlDU6uQBtqr4Eajni3wX7rpfXD8ysFx8z0+dri+KWEPWpBsxXfxu58x/0jvTVT1ekOSg=="],
|
||||
|
||||
"terminal-size": ["terminal-size@4.0.1", "", {}, "sha512-avMLDQpUI9I5XFrklECw1ZEUPJhqzcwSWsyyI8blhRLT+8N1jLJWLWWYQpB2q2xthq8xDvjZPISVh53T/+CLYQ=="],
|
||||
|
||||
"text-decoder": ["text-decoder@1.2.7", "", { "dependencies": { "b4a": "^1.6.4" } }, "sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ=="],
|
||||
|
||||
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||
|
||||
"type-fest": ["type-fest@5.4.4", "", { "dependencies": { "tagged-tag": "^1.0.0" } }, "sha512-JnTrzGu+zPV3aXIUhnyWJj4z/wigMsdYajGLIYakqyOW1nPllzXEJee0QQbHj+CTIQtXGlAjuK0UY+2xTyjVAw=="],
|
||||
|
||||
"typed-query-selector": ["typed-query-selector@2.12.0", "", {}, "sha512-SbklCd1F0EiZOyPiW192rrHZzZ5sBijB6xM+cpmrwDqObvdtunOHHIk9fCGsoK5JVIYXoyEp4iEdE3upFH3PAg=="],
|
||||
|
||||
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
||||
|
||||
"undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
|
||||
|
||||
"webdriver-bidi-protocol": ["webdriver-bidi-protocol@0.4.1", "", {}, "sha512-ARrjNjtWRRs2w4Tk7nqrf2gBI0QXWuOmMCx2hU+1jUt6d00MjMxURrhxhGbrsoiZKJrhTSTzbIrc554iKI10qw=="],
|
||||
|
||||
"widest-line": ["widest-line@6.0.0", "", { "dependencies": { "string-width": "^8.1.0" } }, "sha512-U89AsyEeAsyoF0zVJBkG9zBgekjgjK7yk9sje3F4IQpXBJ10TF6ByLlIfjMhcmHMJgHZI4KHt4rdNfktzxIAMA=="],
|
||||
|
||||
"wrap-ansi": ["wrap-ansi@9.0.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "string-width": "^7.0.0", "strip-ansi": "^7.1.0" } }, "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww=="],
|
||||
|
||||
"wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="],
|
||||
|
||||
"ws": ["ws@8.19.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg=="],
|
||||
|
||||
"y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="],
|
||||
|
||||
"yargs": ["yargs@17.7.2", "", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w=="],
|
||||
|
||||
"yargs-parser": ["yargs-parser@21.1.1", "", {}, "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw=="],
|
||||
|
||||
"yauzl": ["yauzl@2.10.0", "", { "dependencies": { "buffer-crc32": "~0.2.3", "fd-slicer": "~1.1.0" } }, "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g=="],
|
||||
|
||||
"yoga-layout": ["yoga-layout@3.2.1", "", {}, "sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ=="],
|
||||
|
||||
"zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="],
|
||||
|
||||
"chromium-bidi/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
|
||||
|
||||
"cli-truncate/slice-ansi": ["slice-ansi@7.1.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "is-fullwidth-code-point": "^5.0.0" } }, "sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w=="],
|
||||
|
||||
"cliui/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
|
||||
|
||||
"cliui/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
|
||||
|
||||
"cliui/wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="],
|
||||
|
||||
"wrap-ansi/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="],
|
||||
|
||||
"yargs/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
|
||||
|
||||
"cliui/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
|
||||
|
||||
"cliui/string-width/is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="],
|
||||
|
||||
"cliui/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
|
||||
|
||||
"cliui/wrap-ansi/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
|
||||
|
||||
"yargs/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
|
||||
|
||||
"yargs/string-width/is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="],
|
||||
|
||||
"yargs/string-width/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
|
||||
|
||||
"yargs/string-width/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,9 @@
|
||||
"scripts": {
|
||||
"start": "bun server.ts",
|
||||
"start:cli": "bun quipslop.tsx",
|
||||
"start:web": "bun --hot server.ts"
|
||||
"start:web": "bun --hot server.ts",
|
||||
"start:stream": "bun ./scripts/stream-browser.ts live",
|
||||
"start:stream:dryrun": "bun ./scripts/stream-browser.ts dryrun"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "latest",
|
||||
@@ -20,6 +22,7 @@
|
||||
"@openrouter/ai-sdk-provider": "^2.2.3",
|
||||
"ai": "^6.0.94",
|
||||
"ink": "^6.8.0",
|
||||
"puppeteer": "^24.2.0",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4"
|
||||
}
|
||||
|
||||
320
scripts/stream-browser.ts
Normal file
320
scripts/stream-browser.ts
Normal file
@@ -0,0 +1,320 @@
|
||||
import puppeteer from "puppeteer";
|
||||
|
||||
type Mode = "live" | "dryrun";
|
||||
|
||||
type SinkWriter = {
|
||||
write(chunk: Uint8Array): number;
|
||||
end(error?: Error): number;
|
||||
};
|
||||
|
||||
function parsePositiveInt(value: string | undefined, fallback: number): number {
|
||||
const parsed = Number.parseInt(value ?? "", 10);
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
|
||||
}
|
||||
|
||||
function usage(): never {
|
||||
console.error("Usage: bun scripts/stream-browser.ts <live|dryrun>");
|
||||
console.error("Required for live mode: TWITCH_STREAM_KEY");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
function resolveMode(value: string | undefined): Mode {
|
||||
if (value === "live" || value === "dryrun") return value;
|
||||
return usage();
|
||||
}
|
||||
|
||||
const mode = resolveMode(process.argv[2]);
|
||||
|
||||
const streamFps = parsePositiveInt(process.env.STREAM_FPS, 30);
|
||||
const captureBitrate = parsePositiveInt(process.env.STREAM_CAPTURE_BITRATE, 12_000_000);
|
||||
const targetSize = process.env.STREAM_TARGET_SIZE ?? "1920x1080";
|
||||
const targetParts = targetSize.split("x");
|
||||
const targetWidth = targetParts[0] ?? "1920";
|
||||
const targetHeight = targetParts[1] ?? "1080";
|
||||
const videoBitrate = process.env.STREAM_VIDEO_BITRATE ?? "6000k";
|
||||
const maxrate = process.env.STREAM_MAXRATE ?? "6000k";
|
||||
const bufsize = process.env.STREAM_BUFSIZE ?? "12000k";
|
||||
const gop = String(parsePositiveInt(process.env.STREAM_GOP, 60));
|
||||
const audioBitrate = process.env.STREAM_AUDIO_BITRATE ?? "160k";
|
||||
const streamKey = process.env.TWITCH_STREAM_KEY;
|
||||
const serverPort = process.env.STREAM_APP_PORT ?? "5109";
|
||||
const broadcastUrl = process.env.BROADCAST_URL ?? `http://127.0.0.1:${serverPort}/broadcast`;
|
||||
|
||||
if (mode === "live" && !streamKey) {
|
||||
console.error("TWITCH_STREAM_KEY is not set.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
async function assertBroadcastReachable(url: string) {
|
||||
const timeoutMs = 5_000;
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
||||
try {
|
||||
const res = await fetch(url, { signal: controller.signal });
|
||||
if (!res.ok) {
|
||||
throw new Error(`HTTP ${res.status}`);
|
||||
}
|
||||
} catch (error) {
|
||||
const detail = error instanceof Error ? error.message : String(error);
|
||||
throw new Error(
|
||||
`Cannot reach broadcast page at ${url} (${detail}). Start the app server first (bun run start or bun run start:web).`,
|
||||
);
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
function buildFfmpegArgs(currentMode: Mode): string[] {
|
||||
const args = [
|
||||
"-hide_banner",
|
||||
"-loglevel",
|
||||
"warning",
|
||||
"-fflags",
|
||||
"+genpts",
|
||||
"-f",
|
||||
"webm",
|
||||
"-i",
|
||||
"pipe:0",
|
||||
"-f",
|
||||
"lavfi",
|
||||
"-i",
|
||||
"anullsrc=channel_layout=stereo:sample_rate=44100",
|
||||
"-map",
|
||||
"0:v:0",
|
||||
"-map",
|
||||
"1:a:0",
|
||||
"-vf",
|
||||
`scale=${targetWidth}:${targetHeight}:force_original_aspect_ratio=decrease,pad=${targetWidth}:${targetHeight}:(ow-iw)/2:(oh-ih)/2`,
|
||||
"-c:v",
|
||||
"libx264",
|
||||
"-preset",
|
||||
"veryfast",
|
||||
"-tune",
|
||||
"zerolatency",
|
||||
"-pix_fmt",
|
||||
"yuv420p",
|
||||
"-b:v",
|
||||
videoBitrate,
|
||||
"-maxrate",
|
||||
maxrate,
|
||||
"-bufsize",
|
||||
bufsize,
|
||||
"-g",
|
||||
gop,
|
||||
"-keyint_min",
|
||||
gop,
|
||||
"-sc_threshold",
|
||||
"0",
|
||||
"-c:a",
|
||||
"aac",
|
||||
"-b:a",
|
||||
audioBitrate,
|
||||
"-ar",
|
||||
"44100",
|
||||
"-ac",
|
||||
"2",
|
||||
];
|
||||
|
||||
if (currentMode === "live") {
|
||||
args.push("-f", "flv", `rtmp://live.twitch.tv/app/${streamKey}`);
|
||||
return args;
|
||||
}
|
||||
|
||||
args.push("-f", "mpegts", "pipe:1");
|
||||
return args;
|
||||
}
|
||||
|
||||
async function pipeReadableToSink(
|
||||
readable: ReadableStream<Uint8Array>,
|
||||
sink: SinkWriter,
|
||||
) {
|
||||
const reader = readable.getReader();
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
if (value) sink.write(value);
|
||||
}
|
||||
} finally {
|
||||
sink.end();
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
await assertBroadcastReachable(broadcastUrl);
|
||||
|
||||
const ffmpegArgs = buildFfmpegArgs(mode);
|
||||
const ffmpeg = Bun.spawn(["ffmpeg", ...ffmpegArgs], {
|
||||
stdin: "pipe",
|
||||
stdout: mode === "dryrun" ? "pipe" : "inherit",
|
||||
stderr: "inherit",
|
||||
});
|
||||
|
||||
let ffplay: Bun.Subprocess | null = null;
|
||||
let ffplayPump: Promise<void> | null = null;
|
||||
if (mode === "dryrun") {
|
||||
ffplay = Bun.spawn(
|
||||
[
|
||||
"ffplay",
|
||||
"-hide_banner",
|
||||
"-fflags",
|
||||
"nobuffer",
|
||||
"-flags",
|
||||
"low_delay",
|
||||
"-framedrop",
|
||||
"-i",
|
||||
"pipe:0",
|
||||
],
|
||||
{
|
||||
stdin: "pipe",
|
||||
stdout: "inherit",
|
||||
stderr: "inherit",
|
||||
},
|
||||
);
|
||||
const stdout = ffmpeg.stdout;
|
||||
if (!stdout || !ffplay.stdin) {
|
||||
throw new Error("Failed to pipe ffmpeg output into ffplay.");
|
||||
}
|
||||
if (typeof ffplay.stdin === "number") {
|
||||
throw new Error("ffplay stdin is not writable.");
|
||||
}
|
||||
ffplayPump = pipeReadableToSink(stdout, ffplay.stdin as SinkWriter);
|
||||
}
|
||||
|
||||
let firstChunkResolve: (() => void) | null = null;
|
||||
let firstChunkReject: ((error: Error) => void) | null = null;
|
||||
const firstChunk = new Promise<void>((resolve, reject) => {
|
||||
firstChunkResolve = resolve;
|
||||
firstChunkReject = reject;
|
||||
});
|
||||
|
||||
const chunkServer = Bun.serve({
|
||||
port: 0,
|
||||
fetch(req, server) {
|
||||
const url = new URL(req.url);
|
||||
if (url.pathname === "/chunks" && server.upgrade(req)) {
|
||||
return;
|
||||
}
|
||||
return new Response("Not found", { status: 404 });
|
||||
},
|
||||
websocket: {
|
||||
message(_ws, message) {
|
||||
if (!ffmpeg.stdin) return;
|
||||
if (typeof message === "string") return;
|
||||
|
||||
let chunk: Uint8Array | null = null;
|
||||
if (message instanceof ArrayBuffer) {
|
||||
chunk = new Uint8Array(message);
|
||||
} else if (ArrayBuffer.isView(message)) {
|
||||
chunk = new Uint8Array(
|
||||
message.buffer,
|
||||
message.byteOffset,
|
||||
message.byteLength,
|
||||
);
|
||||
}
|
||||
if (!chunk) return;
|
||||
|
||||
try {
|
||||
ffmpeg.stdin.write(chunk);
|
||||
firstChunkResolve?.();
|
||||
firstChunkResolve = null;
|
||||
firstChunkReject = null;
|
||||
} catch (error) {
|
||||
const detail = error instanceof Error ? error : new Error(String(error));
|
||||
firstChunkReject?.(detail);
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const browser = await puppeteer.launch({
|
||||
headless: true,
|
||||
args: [
|
||||
"--autoplay-policy=no-user-gesture-required",
|
||||
"--disable-background-timer-throttling",
|
||||
"--disable-renderer-backgrounding",
|
||||
"--disable-backgrounding-occluded-windows",
|
||||
],
|
||||
});
|
||||
|
||||
const page = await browser.newPage();
|
||||
await page.setViewport({ width: 1920, height: 1080, deviceScaleFactor: 1 });
|
||||
page.on("console", (msg) => {
|
||||
if (process.env.STREAM_DEBUG === "1") {
|
||||
console.log(`[broadcast] ${msg.type()}: ${msg.text()}`);
|
||||
}
|
||||
});
|
||||
|
||||
const captureUrl = new URL(broadcastUrl);
|
||||
captureUrl.searchParams.set("sink", `ws://127.0.0.1:${chunkServer.port}/chunks`);
|
||||
captureUrl.searchParams.set("captureFps", String(streamFps));
|
||||
captureUrl.searchParams.set("captureBitrate", String(captureBitrate));
|
||||
|
||||
await page.goto(captureUrl.toString(), { waitUntil: "networkidle2" });
|
||||
await page.waitForSelector("#broadcast-canvas", { timeout: 10_000 });
|
||||
|
||||
const firstChunkTimer = setTimeout(() => {
|
||||
firstChunkReject?.(
|
||||
new Error("No media chunks received from headless browser within 10s."),
|
||||
);
|
||||
}, 10_000);
|
||||
|
||||
await firstChunk.finally(() => clearTimeout(firstChunkTimer));
|
||||
console.log(`Streaming from ${broadcastUrl} in ${mode} mode`);
|
||||
|
||||
let shuttingDown = false;
|
||||
const shutdown = async () => {
|
||||
if (shuttingDown) return;
|
||||
shuttingDown = true;
|
||||
try {
|
||||
chunkServer.stop(true);
|
||||
} catch {}
|
||||
try {
|
||||
await browser.close();
|
||||
} catch {}
|
||||
try {
|
||||
ffmpeg.stdin?.end();
|
||||
} catch {}
|
||||
try {
|
||||
ffmpeg.kill();
|
||||
} catch {}
|
||||
if (ffplay) {
|
||||
try {
|
||||
if (ffplay.stdin && typeof ffplay.stdin !== "number") {
|
||||
ffplay.stdin.end();
|
||||
}
|
||||
} catch {}
|
||||
try {
|
||||
ffplay.kill();
|
||||
} catch {}
|
||||
}
|
||||
};
|
||||
|
||||
process.on("SIGINT", () => {
|
||||
void shutdown();
|
||||
});
|
||||
process.on("SIGTERM", () => {
|
||||
void shutdown();
|
||||
});
|
||||
|
||||
const exitCode = await ffmpeg.exited;
|
||||
if (ffplayPump) {
|
||||
await ffplayPump.catch(() => {
|
||||
// Ignore downstream pipe failures on shutdown.
|
||||
});
|
||||
}
|
||||
if (ffplay) {
|
||||
await ffplay.exited;
|
||||
}
|
||||
await shutdown();
|
||||
|
||||
if (exitCode !== 0) {
|
||||
process.exit(exitCode);
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
const detail = error instanceof Error ? error.message : String(error);
|
||||
console.error(detail);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -3,6 +3,7 @@ import { timingSafeEqual } from "node:crypto";
|
||||
import indexHtml from "./index.html";
|
||||
import historyHtml from "./history.html";
|
||||
import adminHtml from "./admin.html";
|
||||
import broadcastHtml from "./broadcast.html";
|
||||
import { clearAllRounds, getRounds, getAllRounds } from "./db.ts";
|
||||
import {
|
||||
MODELS,
|
||||
@@ -251,6 +252,7 @@ const server = Bun.serve<WsData>({
|
||||
"/": indexHtml,
|
||||
"/history": historyHtml,
|
||||
"/admin": adminHtml,
|
||||
"/broadcast": broadcastHtml,
|
||||
},
|
||||
async fetch(req, server) {
|
||||
const url = new URL(req.url);
|
||||
|
||||
Reference in New Issue
Block a user