Initial commit: Twitch Stream Vision Analyzer
Async pipeline: streamlink + ffmpeg frame capture → Gemini Vision API analysis → rich console output + log file. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
3
.env.example
Normal file
3
.env.example
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
GEMINI_API_KEY=your-api-key-here
|
||||||
|
# Optional: Cloudflare AI Gateway URL
|
||||||
|
# GEMINI_BASE_URL=https://gateway.ai.cloudflare.com/v1/your-account/your-gateway/google-ai-studio
|
||||||
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
.env
|
||||||
|
stream_log.txt
|
||||||
77
analyzer.py
Normal file
77
analyzer.py
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
import base64
|
||||||
|
|
||||||
|
from google import genai
|
||||||
|
from google.genai import types
|
||||||
|
|
||||||
|
SYSTEM_PROMPT_RU = (
|
||||||
|
"Ты анализируешь кадры с Twitch-стрима. "
|
||||||
|
"Кратко опиши что происходит на экране: игра, действия стримера, "
|
||||||
|
"интерфейс, чат, оверлеи. Будь лаконичен (2-3 предложения). "
|
||||||
|
"Если ничего не изменилось по сравнению с предыдущим описанием, "
|
||||||
|
"скажи 'Без изменений' и уточни только новые детали."
|
||||||
|
)
|
||||||
|
|
||||||
|
SYSTEM_PROMPT_EN = (
|
||||||
|
"You are analyzing frames from a Twitch stream. "
|
||||||
|
"Briefly describe what's happening on screen: game, streamer actions, "
|
||||||
|
"UI, chat, overlays. Be concise (2-3 sentences). "
|
||||||
|
"If nothing changed compared to the previous description, "
|
||||||
|
"say 'No changes' and only note new details."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class VisionAnalyzer:
|
||||||
|
def __init__(self, api_key: str, base_url: str | None = None, lang: str = "ru"):
|
||||||
|
client_kwargs = {"api_key": api_key}
|
||||||
|
if base_url:
|
||||||
|
client_kwargs["http_options"] = types.HttpOptions(base_url=base_url)
|
||||||
|
self.client = genai.Client(**client_kwargs)
|
||||||
|
self.model = "gemini-2.0-flash"
|
||||||
|
self.system_prompt = SYSTEM_PROMPT_RU if lang == "ru" else SYSTEM_PROMPT_EN
|
||||||
|
self.previous_description: str | None = None
|
||||||
|
|
||||||
|
async def analyze_frame(self, frame_data: bytes) -> str:
|
||||||
|
b64_image = base64.b64encode(frame_data).decode("utf-8")
|
||||||
|
|
||||||
|
contents = []
|
||||||
|
if self.previous_description:
|
||||||
|
contents.append(
|
||||||
|
types.Content(
|
||||||
|
role="user",
|
||||||
|
parts=[
|
||||||
|
types.Part.from_text(
|
||||||
|
text=f"Предыдущее описание: {self.previous_description}"
|
||||||
|
)
|
||||||
|
],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
contents.append(
|
||||||
|
types.Content(
|
||||||
|
role="model",
|
||||||
|
parts=[types.Part.from_text(text="Понял, учту контекст.")],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
contents.append(
|
||||||
|
types.Content(
|
||||||
|
role="user",
|
||||||
|
parts=[
|
||||||
|
types.Part.from_bytes(data=frame_data, mime_type="image/jpeg"),
|
||||||
|
types.Part.from_text(text="Опиши что сейчас происходит на стриме."),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
response = await self.client.aio.models.generate_content(
|
||||||
|
model=self.model,
|
||||||
|
contents=contents,
|
||||||
|
config=types.GenerateContentConfig(
|
||||||
|
system_instruction=self.system_prompt,
|
||||||
|
max_output_tokens=300,
|
||||||
|
temperature=0.3,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
description = response.text or "(нет описания)"
|
||||||
|
self.previous_description = description
|
||||||
|
return description
|
||||||
76
capture.py
Normal file
76
capture.py
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
import asyncio
|
||||||
|
import struct
|
||||||
|
from collections.abc import AsyncIterator
|
||||||
|
|
||||||
|
|
||||||
|
async def capture_frames(
|
||||||
|
channel: str, quality: str, interval: int
|
||||||
|
) -> AsyncIterator[bytes]:
|
||||||
|
"""Capture frames from a Twitch stream using streamlink + ffmpeg.
|
||||||
|
|
||||||
|
Yields JPEG frames as bytes at the specified interval.
|
||||||
|
"""
|
||||||
|
streamlink_cmd = [
|
||||||
|
"streamlink",
|
||||||
|
"--stdout",
|
||||||
|
f"https://twitch.tv/{channel}",
|
||||||
|
quality,
|
||||||
|
]
|
||||||
|
|
||||||
|
ffmpeg_cmd = [
|
||||||
|
"ffmpeg",
|
||||||
|
"-i", "pipe:0",
|
||||||
|
"-vf", f"fps=1/{interval}",
|
||||||
|
"-f", "image2pipe",
|
||||||
|
"-vcodec", "mjpeg",
|
||||||
|
"-q:v", "5",
|
||||||
|
"pipe:1",
|
||||||
|
]
|
||||||
|
|
||||||
|
streamlink_proc = await asyncio.create_subprocess_exec(
|
||||||
|
*streamlink_cmd,
|
||||||
|
stdout=asyncio.subprocess.PIPE,
|
||||||
|
stderr=asyncio.subprocess.DEVNULL,
|
||||||
|
)
|
||||||
|
|
||||||
|
ffmpeg_proc = await asyncio.create_subprocess_exec(
|
||||||
|
*ffmpeg_cmd,
|
||||||
|
stdin=streamlink_proc.stdout,
|
||||||
|
stdout=asyncio.subprocess.PIPE,
|
||||||
|
stderr=asyncio.subprocess.DEVNULL,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Release streamlink's stdout so ffmpeg owns the pipe
|
||||||
|
streamlink_proc.stdout = None
|
||||||
|
|
||||||
|
try:
|
||||||
|
buf = b""
|
||||||
|
while True:
|
||||||
|
chunk = await ffmpeg_proc.stdout.read(65536)
|
||||||
|
if not chunk:
|
||||||
|
break
|
||||||
|
buf += chunk
|
||||||
|
|
||||||
|
# Extract complete JPEG frames (SOI: FF D8, EOI: FF D9)
|
||||||
|
while True:
|
||||||
|
soi = buf.find(b"\xff\xd8")
|
||||||
|
if soi == -1:
|
||||||
|
buf = b""
|
||||||
|
break
|
||||||
|
eoi = buf.find(b"\xff\xd9", soi + 2)
|
||||||
|
if eoi == -1:
|
||||||
|
# Keep from SOI onward, discard junk before
|
||||||
|
buf = buf[soi:]
|
||||||
|
break
|
||||||
|
frame = buf[soi : eoi + 2]
|
||||||
|
buf = buf[eoi + 2 :]
|
||||||
|
yield frame
|
||||||
|
finally:
|
||||||
|
for proc in (ffmpeg_proc, streamlink_proc):
|
||||||
|
try:
|
||||||
|
proc.terminate()
|
||||||
|
except ProcessLookupError:
|
||||||
|
pass
|
||||||
|
await asyncio.gather(
|
||||||
|
ffmpeg_proc.wait(), streamlink_proc.wait(), return_exceptions=True
|
||||||
|
)
|
||||||
42
config.py
Normal file
42
config.py
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import argparse
|
||||||
|
import os
|
||||||
|
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
|
||||||
|
def load_config() -> argparse.Namespace:
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
parser = argparse.ArgumentParser(description="Twitch Stream Vision Analyzer")
|
||||||
|
parser.add_argument("--channel", required=True, help="Twitch channel name")
|
||||||
|
parser.add_argument(
|
||||||
|
"--interval",
|
||||||
|
type=int,
|
||||||
|
default=15,
|
||||||
|
help="Frame capture interval in seconds (default: 15)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--quality",
|
||||||
|
default="480p",
|
||||||
|
help="Stream quality (default: 480p)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--lang",
|
||||||
|
default="ru",
|
||||||
|
help="Language for descriptions (default: ru)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--log-file",
|
||||||
|
default="stream_log.txt",
|
||||||
|
help="Log file path (default: stream_log.txt)",
|
||||||
|
)
|
||||||
|
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
args.gemini_api_key = os.environ.get("GEMINI_API_KEY")
|
||||||
|
if not args.gemini_api_key:
|
||||||
|
parser.error("GEMINI_API_KEY must be set in .env or environment")
|
||||||
|
|
||||||
|
args.gemini_base_url = os.environ.get("GEMINI_BASE_URL")
|
||||||
|
|
||||||
|
return args
|
||||||
71
main.py
Normal file
71
main.py
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
import asyncio
|
||||||
|
import signal
|
||||||
|
|
||||||
|
from rich.console import Console
|
||||||
|
|
||||||
|
from config import load_config
|
||||||
|
from capture import capture_frames
|
||||||
|
from analyzer import VisionAnalyzer
|
||||||
|
from output import print_description, log_description
|
||||||
|
|
||||||
|
console = Console()
|
||||||
|
|
||||||
|
|
||||||
|
async def run(config) -> None:
|
||||||
|
analyzer = VisionAnalyzer(
|
||||||
|
api_key=config.gemini_api_key,
|
||||||
|
base_url=config.gemini_base_url,
|
||||||
|
lang=config.lang,
|
||||||
|
)
|
||||||
|
|
||||||
|
console.print(
|
||||||
|
f"[bold green]Starting stream analysis[/bold green] "
|
||||||
|
f"channel=[cyan]{config.channel}[/cyan] "
|
||||||
|
f"interval=[cyan]{config.interval}s[/cyan] "
|
||||||
|
f"quality=[cyan]{config.quality}[/cyan]"
|
||||||
|
)
|
||||||
|
console.print("[dim]Press Ctrl+C to stop[/dim]\n")
|
||||||
|
|
||||||
|
frame_number = 0
|
||||||
|
|
||||||
|
async for frame_data in capture_frames(
|
||||||
|
config.channel, config.quality, config.interval
|
||||||
|
):
|
||||||
|
frame_number += 1
|
||||||
|
console.print(f"[dim]Captured frame #{frame_number}, analyzing...[/dim]")
|
||||||
|
|
||||||
|
try:
|
||||||
|
description = await analyzer.analyze_frame(frame_data)
|
||||||
|
except Exception as e:
|
||||||
|
console.print(f"[bold red]Analysis error:[/bold red] {e}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
print_description(description, frame_number)
|
||||||
|
await log_description(config.log_file, description, frame_number)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
config = load_config()
|
||||||
|
|
||||||
|
loop = asyncio.new_event_loop()
|
||||||
|
asyncio.set_event_loop(loop)
|
||||||
|
|
||||||
|
task = loop.create_task(run(config))
|
||||||
|
|
||||||
|
def shutdown(sig, frame):
|
||||||
|
console.print("\n[bold yellow]Shutting down...[/bold yellow]")
|
||||||
|
task.cancel()
|
||||||
|
|
||||||
|
signal.signal(signal.SIGINT, shutdown)
|
||||||
|
signal.signal(signal.SIGTERM, shutdown)
|
||||||
|
|
||||||
|
try:
|
||||||
|
loop.run_until_complete(task)
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
console.print("[bold green]Stopped.[/bold green]")
|
||||||
|
finally:
|
||||||
|
loop.close()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
27
output.py
Normal file
27
output.py
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import aiofiles
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from rich.console import Console
|
||||||
|
from rich.panel import Panel
|
||||||
|
|
||||||
|
console = Console()
|
||||||
|
|
||||||
|
|
||||||
|
def print_description(description: str, frame_number: int) -> None:
|
||||||
|
timestamp = datetime.now().strftime("%H:%M:%S")
|
||||||
|
console.print(
|
||||||
|
Panel(
|
||||||
|
description,
|
||||||
|
title=f"[bold cyan]Frame #{frame_number}[/bold cyan] [{timestamp}]",
|
||||||
|
border_style="blue",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def log_description(
|
||||||
|
log_file: str, description: str, frame_number: int
|
||||||
|
) -> None:
|
||||||
|
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||||
|
line = f"[{timestamp}] Frame #{frame_number}: {description}\n"
|
||||||
|
async with aiofiles.open(log_file, "a", encoding="utf-8") as f:
|
||||||
|
await f.write(line)
|
||||||
5
requirements.txt
Normal file
5
requirements.txt
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
streamlink
|
||||||
|
google-genai
|
||||||
|
python-dotenv
|
||||||
|
rich
|
||||||
|
aiofiles
|
||||||
Reference in New Issue
Block a user