Add initial implementation of StreamShot with snapshot capture feature and UI
This commit is contained in:
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
*.jpg
|
||||
66
README.md
Normal file
66
README.md
Normal 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
176
main.py
Normal 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
2
requirements.txt
Normal file
@@ -0,0 +1,2 @@
|
||||
yt-dlp
|
||||
opencv-python
|
||||
157
ui.html
Normal file
157
ui.html
Normal 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>
|
||||
Reference in New Issue
Block a user