Add initial implementation of StreamShot with snapshot capture feature and UI

This commit is contained in:
Space-Banane
2026-04-01 17:12:28 +02:00
commit 9bc7a648af
5 changed files with 402 additions and 0 deletions

1
.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
*.jpg

66
README.md Normal file
View File

@@ -0,0 +1,66 @@
# StreamShot 📸
Capture high-quality JPEG snapshots from any YouTube livestream via a simple web UI or API.
(yes it's a yt-dlp wrapper)
## Features
- **Stream Extraction**: Uses `yt-dlp` to find the best available stream URL.
- **Frame Capture**: Uses OpenCV to grab a single frame directly from the video stream.
- **Web UI**: A clean, Tailwind CSS-powered interface for quick captures.
- **API Access**: Returns snapshots as base64-encoded buffers in a JSON object.
## API Usage
### Endpoint
`POST /snapshot`
### Request Body
```json
{
"url": "https://www.youtube.com/watch?v=..."
}
```
### Response
```json
{
"message": "done",
"buffer": "/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAIBAQEBAQIBAQECAgICAg..."
}
```
### CLI Example (Save to File)
To capture a snapshot and save it directly to a file using `curl`, `jq`, and `base64`:
```bash
curl -X POST \
https://shsf-api.reversed.dev/api/exec/16/cca815a2-216d-4362-b6f5-1d57ac9f088d/snapshot \
-H "Content-Type: application/json" \
-d '{"url": "YOUR_YOUTUBE_URL_HERE"}' \
| jq -r .buffer | base64 -d > snapshot.jpg
```
## Local Development
### Prerequisites
- Python 3.14 (tested)
- `opencv-python`
- `yt-dlp`
- `numpy`
### Installation
```bash
pip install -r requirements.txt
```
### Running Locally
You can test the capture logic directly:
```bash
python main.py
```
## Tech Stack
- **Backend**: Python (yt-dlp, OpenCV)
- **Frontend**: HTML5, Tailwind CSS, JavaScript (Fetch API)
- **Deployment**: Shsf (Serverless)

176
main.py Normal file
View File

@@ -0,0 +1,176 @@
import cv2
import yt_dlp
import numpy as np
import json
def capture_stream_snapshot(youtube_url, output_file="snapshot.jpg", toBuffer=False):
# 1. Configure yt-dlp to get the direct stream URL
ydl_opts = {
"format": "best",
"quiet": True,
"no_warnings": True,
}
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
try:
info = ydl.extract_info(youtube_url, download=False)
stream_url = info["url"]
except Exception as e:
print(f"Error extracting info: {e}")
return None
# 2. Use OpenCV to capture a frame from the stream URL
try:
cap = cv2.VideoCapture(stream_url)
except Exception as e:
print(f"Error opening VideoCapture: {e}")
return None
if not cap.isOpened():
print("Error: Could not open video stream.")
return None if toBuffer else None
# Read a single frame
success, frame = cap.read()
if success:
if toBuffer:
# Encode frame as JPEG to memory buffer
ret, buf = cv2.imencode(".jpg", frame)
if ret:
print("Snapshot captured to buffer.")
cap.release()
return buf.tobytes()
else:
print("Error: Could not encode frame to buffer.")
cap.release()
return None
else:
# 3. Save the frame as an image
cv2.imwrite(output_file, frame)
print(f"Snapshot saved to {output_file}")
else:
print("Error: Could not read frame from stream.")
# Cleanup
cap.release()
# Shsf Handler
def main(args):
route = args.get("route")
if route == "snapshot":
url = args.get("body", "")
try:
url = json.loads(url)
except json.JSONDecodeError:
print("Error: Invalid JSON input.")
return {
"_shsf": "v2",
"_code": 400,
"_res": {"message": "Invalid JSON input."},
"_headers": {
"Content-Type": "application/json",
},
}
url = url.get("url", "")
if not url:
print("Error: URL not provided.")
return {
"_shsf": "v2",
"_code": 400,
"_res": {"message": "URL not provided."},
"_headers": {
"Content-Type": "application/json",
},
}
try:
capture_stream_snapshot(url, output_file="/tmp/snapshot.jpg")
content = None
with open("/tmp/snapshot.jpg", "rb") as f:
content = f.read()
except Exception as e:
print(f"Error: {e}")
return {
"_shsf": "v2",
"_code": 500,
"_res": {"message": f"Could not capture snapshot: {str(e)}"},
"_headers": {
"Content-Type": "application/json",
},
}
if content is None:
print("Error: Could not capture snapshot.")
return {
"_shsf": "v2",
"_code": 500,
"_res": {"message": "Could not capture snapshot."},
"_headers": {
"Content-Type": "application/json",
},
}
# Convert content to base64 for JSON response
import base64
encoded_content = base64.b64encode(content).decode('utf-8')
return {
"_shsf": "v2",
"_code": 200,
"_res": {
"message": "done",
"buffer": encoded_content
},
"_headers": {
"Content-Type": "application/json",
},
}
elif route == "health":
return {
"_shsf": "v2",
"_code": 200,
"_res": {"message": "Service is healthy."},
"_headers": {
"Content-Type": "application/json",
},
}
elif route == "default": # UI Route
try:
content = ""
with open("/app/ui.html", "r") as f:
content = f.read()
except FileNotFoundError:
print("Error: /app/ui.html not found.")
return {
"_shsf": "v2",
"_code": 404,
"_res": {"message": "UI file not found."},
"_headers": {
"Content-Type": "application/json",
},
}
except Exception as e:
print(f"Error loading UI: {e}")
return {
"_shsf": "v2",
"_code": 500,
"_res": {"message": f"Server error: {str(e)}"},
"_headers": {
"Content-Type": "application/json",
},
}
return {
"_shsf": "v2",
"_code": 200,
"_res": content,
"_headers": {
"Content-Type": "text/html",
},
}
if __name__ == "__main__":
print("omg im on main")
capture_stream_snapshot("https://www.youtube.com/watch?v=n15V_fCsl_c", output_file="snapshot.jpg")

2
requirements.txt Normal file
View File

@@ -0,0 +1,2 @@
yt-dlp
opencv-python

157
ui.html Normal file
View File

@@ -0,0 +1,157 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>StreamShot</title>
<script src="https://cdn.tailwindcss.com"></script>
<style>
body {
background-color: #0f172a;
color: #f1f5f9;
}
.sh-card {
background: rgba(30, 41, 59, 0.7);
backdrop-filter: blur(8px);
border: 1px solid rgba(255, 255, 255, 0.1);
}
</style>
</head>
<body class="min-h-screen flex flex-col items-center justify-center p-4">
<div class="max-w-2xl w-full space-y-8 text-center">
<header>
<h1 class="text-4xl font-extrabold tracking-tight text-white mb-2">StreamShot</h1>
<p class="text-slate-400">Capture a high-quality frame from any YouTube livestream</p>
</header>
<div class="sh-card p-6 rounded-2xl shadow-xl">
<div class="flex flex-col sm:flex-row gap-3">
<input
type="text"
id="streamUrl"
placeholder="Enter YouTube URL..."
class="flex-1 bg-slate-900 border border-slate-700 rounded-xl px-4 py-3 focus:outline-none focus:ring-2 focus:ring-blue-500 transition-all text-white placeholder-slate-500"
>
<button
onclick="captureSnapshot()"
id="snapBtn"
class="bg-blue-600 hover:bg-blue-500 text-white font-semibold py-3 px-8 rounded-xl transition-all shadow-lg active:scale-95 disabled:opacity-50 disabled:cursor-not-allowed"
>
Capture
</button>
</div>
<div id="status" class="mt-4 text-sm font-medium hidden text-blue-400"></div>
</div>
<div id="imageContainer" class="hidden space-y-4">
<div class="sh-card p-2 rounded-2xl overflow-hidden shadow-2xl">
<img id="snapshotImg" class="w-full rounded-xl" src="" alt="Snapshot">
</div>
<div class="flex justify-center gap-4">
<a
id="downloadBtn"
href="#"
download="snapshot.jpg"
class="inline-flex items-center gap-2 bg-blue-600 hover:bg-blue-500 text-white py-2.5 px-6 rounded-xl transition-all shadow-md"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M3 17a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zm3.293-7.707a1 1 0 011.414 0L9 10.586V3a1 1 0 112 0v7.586l1.293-1.293a1 1 0 111.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z" clip-rule="evenodd" />
</svg>
Download Image
</a>
</div>
</div>
<footer class="mt-12 text-left bg-slate-900/50 rounded-2xl p-6 border border-slate-800">
<h2 class="text-lg font-semibold text-white mb-4">API Documentation (for AI/CLI)</h2>
<div class="space-y-4">
<div>
<p class="text-sm text-slate-400 mb-2">To capture a snapshot and save the binary image directly using <code class="bg-slate-800 px-1.5 py-0.5 rounded text-blue-300">curl</code> and <code class="bg-slate-800 px-1.5 py-0.5 rounded text-blue-300">jq</code>:</p>
<pre class="bg-black/40 p-4 rounded-xl text-xs overflow-x-auto text-slate-300 border border-slate-700"><code>curl -X POST \
https://shsf-api.reversed.dev/api/exec/16/cca815a2-216d-4362-b6f5-1d57ac9f088d/snapshot \
-H "Content-Type: application/json" \
-d '{"url": "https://www.youtube.com/watch?v=STREAMID"}' \
| jq -r .buffer | base64 -d > snapshot.jpg</code></pre>
</div>
<div class="text-xs text-slate-500 italic">
Note: The response is a JSON object with a base64 encoded string in the <code class="text-slate-400">buffer</code> field.
</div>
</div>
</footer>
</div>
<script>
const API_BASE = 'https://shsf-api.reversed.dev/api/exec/16/cca815a2-216d-4362-b6f5-1d57ac9f088d';
async function captureSnapshot() {
const urlInput = document.getElementById('streamUrl');
const snapBtn = document.getElementById('snapBtn');
const status = document.getElementById('status');
const imgContainer = document.getElementById('imageContainer');
const snapshotImg = document.getElementById('snapshotImg');
const downloadBtn = document.getElementById('downloadBtn');
const youtubeUrl = urlInput.value.trim();
if (!youtubeUrl) {
status.textContent = 'Please enter a valid YouTube URL';
status.classList.remove('hidden', 'text-blue-400');
status.classList.add('text-red-400');
return;
}
// Reset UI
snapBtn.disabled = true;
snapBtn.textContent = 'Capturing...';
status.textContent = 'Extracting stream and capturing frame...';
status.classList.remove('hidden', 'text-red-400');
status.classList.add('text-blue-400');
imgContainer.classList.add('hidden');
try {
// The API expects a POST to /snapshot with a JSON body { "url": "..." }
const response = await fetch(API_BASE + '/snapshot', {
method: 'POST',
body: JSON.stringify({ url: youtubeUrl }),
headers: {
'Content-Type': 'application/json'
}
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.message || `Server error: ${response.status}`);
}
const data = await response.json();
const binaryString = atob(data.buffer);
const bytes = new Uint8Array(binaryString.length);
for (let i = 0; i < binaryString.length; i++) {
bytes[i] = binaryString.charCodeAt(i);
}
const blob = new Blob([bytes], { type: 'image/jpeg' });
const objectUrl = URL.createObjectURL(blob);
snapshotImg.src = objectUrl;
downloadBtn.href = objectUrl;
imgContainer.classList.remove('hidden');
status.classList.add('hidden');
} catch (err) {
console.error(err);
status.textContent = `Error: ${err.message}`;
status.classList.remove('text-blue-400');
status.classList.add('text-red-400');
} finally {
snapBtn.disabled = false;
snapBtn.textContent = 'Capture';
}
}
// Add Enter key support
document.getElementById('streamUrl').addEventListener('keypress', (e) => {
if (e.key === 'Enter') captureSnapshot();
});
</script>
</body>
</html>