commit ac01354b593fb62eb11b264bc52350f4fef8fb98 Author: CN-JS-HuiBai Date: Thu Apr 2 16:43:22 2026 +0800 first commit diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..1f93d06 --- /dev/null +++ b/.env.example @@ -0,0 +1,11 @@ +PORT=3000 + +# AWS S3 / MinIO Configuration +AWS_REGION=us-east-1 +AWS_ACCESS_KEY_ID=your_access_key +AWS_SECRET_ACCESS_KEY=your_secret_key +S3_BUCKET_NAME=your_s3_bucket_name + +# For MinIO or other S3-compatible storage +S3_ENDPOINT=http://127.0.0.1:9000 +S3_FORCE_PATH_STYLE=true diff --git a/README.md b/README.md new file mode 100644 index 0000000..6eb973c --- /dev/null +++ b/README.md @@ -0,0 +1,25 @@ +# Media Coding Web - Configuration & Run Guide + +To properly test and run this project, you will need to prepare your environment: + +1. **Install Node.js & FFmpeg**: + - Ensure Node.js (v18+) is installed. + - Install **FFmpeg** on your system and make sure it is available in your PATH environment variable. The Node.js library `fluent-ffmpeg` requires it. + +2. **AWS S3 / MinIO Configuration**: + - Modify the `.env` file (copy from `.env.example`). + - Add your `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, `AWS_REGION`, and `S3_BUCKET_NAME`. + - If using **MinIO**, ensure you set the `S3_ENDPOINT` (e.g., `http://127.0.0.1:9000`) and set `S3_FORCE_PATH_STYLE=true`. + - Ensure your bucket has `.mp4` video files. + +3. **Install Dependencies & Start**: + ```bash + npm install + npm start + ``` + +4. **Test Delivery**: + - Open your browser to `http://localhost:3000`. + - The application should display available `.mp4` items from your S3 bucket. + - Click one video, and the Node server will begin to read the S3 stream and pipe it to FFmpeg, transcoding it into HLS segments inside the `/public/hls/` directory. + - The frontend polls via `/api/status`, and once the index playlist is available, HLS playback starts! diff --git a/package.json b/package.json new file mode 100644 index 0000000..6553527 --- /dev/null +++ b/package.json @@ -0,0 +1,18 @@ +{ + "name": "media-coding-web", + "version": "1.0.0", + "description": "Video transcoding and streaming Web application boilerplate", + "main": "server.js", + "scripts": { + "start": "node server.js", + "dev": "node server.js" + }, + "dependencies": { + "@aws-sdk/client-s3": "^3.540.0", + "@aws-sdk/s3-request-presigner": "^3.540.0", + "cors": "^2.8.5", + "dotenv": "^16.4.5", + "express": "^4.19.2", + "fluent-ffmpeg": "^2.1.3" + } +} diff --git a/public/css/style.css b/public/css/style.css new file mode 100644 index 0000000..509068b --- /dev/null +++ b/public/css/style.css @@ -0,0 +1,317 @@ +:root { + --bg-dark: #0f172a; + --panel-bg: rgba(30, 41, 59, 0.7); + --panel-border: rgba(255, 255, 255, 0.1); + --text-primary: #f8fafc; + --text-secondary: #94a3b8; + --accent: #3b82f6; + --accent-hover: #2563eb; + --accent-glow: rgba(59, 130, 246, 0.5); + --font-main: 'Inter', sans-serif; +} + +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + font-family: var(--font-main); + background-color: var(--bg-dark); + color: var(--text-primary); + min-height: 100vh; + display: flex; + justify-content: center; + position: relative; + overflow-x: hidden; +} + +.background-effects { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + z-index: -1; + overflow: hidden; +} + +.glow-orb { + position: absolute; + width: 600px; + height: 600px; + border-radius: 50%; + filter: blur(80px); + opacity: 0.4; + animation: float 20s infinite ease-in-out alternate; +} + +.orb-1 { + top: -200px; + left: -100px; + background: radial-gradient(circle, #3b82f6, transparent 70%); +} + +.orb-2 { + bottom: -200px; + right: -100px; + background: radial-gradient(circle, #8b5cf6, transparent 70%); + animation-delay: -10s; +} + +@keyframes float { + 0% { transform: translate(0, 0); } + 100% { transform: translate(100px, 50px); } +} + +.container { + width: 100%; + max-width: 1200px; + padding: 2rem; + display: flex; + flex-direction: column; + gap: 2rem; +} + +header { + text-align: center; + margin-bottom: 1rem; +} + +header h1 { + font-size: 3rem; + font-weight: 800; + letter-spacing: -1px; + margin-bottom: 0.5rem; +} + +header h1 span { + background: linear-gradient(135deg, #3b82f6, #8b5cf6); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; +} + +header p { + color: var(--text-secondary); + font-size: 1.1rem; +} + +.dashboard { + display: grid; + grid-template-columns: 1fr 2fr; + gap: 2rem; + align-items: flex-start; +} + +.glass-panel { + background: var(--panel-bg); + backdrop-filter: blur(16px); + -webkit-backdrop-filter: blur(16px); + border: 1px solid var(--panel-border); + border-radius: 16px; + padding: 1.5rem; + box-shadow: 0 20px 40px rgba(0, 0, 0, 0.4); +} + +.section-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 1.5rem; + border-bottom: 1px solid var(--panel-border); + padding-bottom: 1rem; +} + +.section-header h2 { + font-size: 1.25rem; + font-weight: 600; +} + +.icon-btn { + background: none; + border: none; + color: var(--text-secondary); + cursor: pointer; + transition: all 0.2s ease; + padding: 0.5rem; + border-radius: 8px; +} + +.icon-btn:hover { + color: var(--text-primary); + background: rgba(255, 255, 255, 0.1); +} + +.video-list { + list-style: none; + display: flex; + flex-direction: column; + gap: 0.5rem; + max-height: 500px; + overflow-y: auto; +} + +.video-list::-webkit-scrollbar { + width: 6px; +} +.video-list::-webkit-scrollbar-thumb { + background: var(--panel-border); + border-radius: 6px; +} + +.video-item { + padding: 1rem; + border-radius: 12px; + background: rgba(255, 255, 255, 0.05); + border: 1px solid transparent; + cursor: pointer; + transition: all 0.3s ease; + display: flex; + align-items: center; + gap: 1rem; +} + +.video-item:hover { + background: rgba(255, 255, 255, 0.1); + transform: translateY(-2px); +} + +.video-item.active { + background: rgba(59, 130, 246, 0.2); + border-color: var(--accent); + box-shadow: 0 0 15px var(--accent-glow); +} + +.video-icon { + width: 40px; + height: 40px; + border-radius: 8px; + background: linear-gradient(135deg, #1e293b, #334155); + display: flex; + align-items: center; + justify-content: center; + color: var(--accent); +} + +.video-info { + flex: 1; + overflow: hidden; +} + +.video-title { + font-weight: 600; + font-size: 0.95rem; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.video-meta { + font-size: 0.8rem; + color: var(--text-secondary); + margin-top: 0.25rem; +} + +.player-wrapper { + position: relative; + width: 100%; + aspect-ratio: 16/9; + background: #000; + border-radius: 12px; + overflow: hidden; + box-shadow: inset 0 0 20px rgba(0,0,0,0.8); +} + +#video-player { + width: 100%; + height: 100%; + object-fit: contain; + outline: none; +} + +.player-overlay { + position: absolute; + inset: 0; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + background: rgba(0, 0, 0, 0.7); + color: white; + text-align: center; + padding: 2rem; + z-index: 10; + transition: opacity 0.3s ease; +} + +.player-overlay h3 { + margin-bottom: 0.5rem; + font-weight: 600; +} + +.player-overlay p { + color: var(--text-secondary); + font-size: 0.9rem; +} + +.now-playing { + margin-top: 1.5rem; + padding-top: 1.5rem; + border-top: 1px solid var(--panel-border); +} + +.now-playing h3 { + font-size: 0.9rem; + color: var(--text-secondary); + text-transform: uppercase; + letter-spacing: 1px; + margin-bottom: 0.5rem; +} + +.now-playing p { + font-size: 1.25rem; + font-weight: 600; +} + +.hidden { + display: none !important; +} + +.spinner-container { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 3rem 0; + gap: 1rem; + color: var(--text-secondary); +} + +.spinner { + width: 40px; + height: 40px; + border: 3px solid rgba(255,255,255,0.1); + border-radius: 50%; + border-top-color: var(--accent); + animation: spin 1s linear infinite; +} + +@keyframes spin { + to { transform: rotate(360deg); } +} + +/* Responsive adjustments */ +@media (max-width: 900px) { + .dashboard { + grid-template-columns: 1fr; + } + + .video-list-section { + order: 2; + } + + .player-section { + order: 1; + } +} diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000..5d45b53 --- /dev/null +++ b/public/index.html @@ -0,0 +1,66 @@ + + + + + + S3 Video Streaming Boilerplate + + + + + + + + + +
+
+
+
+ +
+
+

S3 Media Transcoder

+

Seamlessly stream videos from AWS S3 dynamically via FFmpeg HLS Transcoding

+
+ +
+
+
+

Available Videos

+ +
+
+
+

Fetching S3 Objects...

+
+ +
+ +
+
+
+

Select a video to start transcoding

+
+ + +
+ +
+
+
+ + + + diff --git a/public/js/main.js b/public/js/main.js new file mode 100644 index 0000000..464cbb6 --- /dev/null +++ b/public/js/main.js @@ -0,0 +1,146 @@ +document.addEventListener('DOMContentLoaded', () => { + const videoListEl = document.getElementById('video-list'); + const loadingSpinner = document.getElementById('loading-spinner'); + const refreshBtn = document.getElementById('refresh-btn'); + const playerOverlay = document.getElementById('player-overlay'); + const transcodingOverlay = document.getElementById('transcoding-overlay'); + const videoPlayer = document.getElementById('video-player'); + const nowPlaying = document.getElementById('now-playing'); + const currentVideoTitle = document.getElementById('current-video-title'); + + let currentPollInterval = null; + + // Fetch list of videos from the backend + const fetchVideos = async () => { + videoListEl.classList.add('hidden'); + loadingSpinner.classList.remove('hidden'); + videoListEl.innerHTML = ''; + + try { + const res = await fetch('/api/videos'); + if (!res.ok) throw new Error('Failed to fetch videos. Check S3 Config.'); + + const data = await res.json(); + + loadingSpinner.classList.add('hidden'); + videoListEl.classList.remove('hidden'); + + if (data.videos.length === 0) { + videoListEl.innerHTML = '

No MP4 videos found in the S3 bucket.

'; + return; + } + + data.videos.forEach(key => { + const li = document.createElement('li'); + li.className = 'video-item'; + li.innerHTML = ` +
+ +
+
+
${key.split('/').pop()}
+
H264 / AAC
+
+ `; + li.addEventListener('click', () => selectVideo(key, li)); + videoListEl.appendChild(li); + }); + } catch (err) { + console.error(err); + loadingSpinner.innerHTML = `

Error: ${err.message}

`; + } + }; + + // Handle video selection and trigger transcode + const selectVideo = async (key, listItemNode) => { + // Update UI + document.querySelectorAll('.video-item').forEach(n => n.classList.remove('active')); + listItemNode.classList.add('active'); + + // Reset player UI + stopPolling(); + playerOverlay.classList.add('hidden'); + videoPlayer.classList.add('hidden'); + videoPlayer.pause(); + + nowPlaying.classList.remove('hidden'); + currentVideoTitle.textContent = key.split('/').pop(); + + transcodingOverlay.classList.remove('hidden'); + + try { + const res = await fetch('/api/transcode', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ key }) + }); + const data = await res.json(); + + if (data.error) throw new Error(data.error); + + // Wait/Poll for HLS playlist to be ready + pollForHlsReady(key, data.hlsUrl); + } catch (err) { + console.error(err); + transcodingOverlay.innerHTML = `

Transcode Failed: ${err.message}

`; + } + }; + + // Poll the backend to check if the generated m3u8 file is accessible + const pollForHlsReady = (key, hlsUrl) => { + let attempts = 0; + const maxAttempts = 60; // 60 seconds max wait for first segment + + currentPollInterval = setInterval(async () => { + attempts++; + try { + const res = await fetch(`/api/status?key=${encodeURIComponent(key)}`); + const data = await res.json(); + + if (data.ready) { + stopPolling(); + playHlsStream(data.hlsUrl); + } else if (attempts >= maxAttempts) { + stopPolling(); + transcodingOverlay.innerHTML = `

Timeout waiting for HLS segments.

`; + } + } catch (err) { + console.error("Poll Error:", err); + } + }, 1000); + }; + + const stopPolling = () => { + if (currentPollInterval) { + clearInterval(currentPollInterval); + currentPollInterval = null; + } + }; + + // Initialize HLS Player + const playHlsStream = (url) => { + transcodingOverlay.classList.add('hidden'); + videoPlayer.classList.remove('hidden'); + + if (Hls.isSupported()) { + const hls = new Hls(); + hls.loadSource(url); + hls.attachMedia(videoPlayer); + hls.on(Hls.Events.MANIFEST_PARSED, () => { + videoPlayer.play().catch(e => console.log('Auto-play blocked')); + }); + } else if (videoPlayer.canPlayType('application/vnd.apple.mpegurl')) { + // Safari uses native HLS + videoPlayer.src = url; + videoPlayer.addEventListener('loadedmetadata', () => { + videoPlayer.play().catch(e => console.log('Auto-play blocked')); + }); + } + }; + + // Bind events + refreshBtn.addEventListener('click', fetchVideos); + + // Initial load + fetchVideos(); +}); diff --git a/server.js b/server.js new file mode 100644 index 0000000..2694aee --- /dev/null +++ b/server.js @@ -0,0 +1,134 @@ +const express = require('express'); +const cors = require('cors'); +const dotenv = require('dotenv'); +const fs = require('fs'); +const path = require('path'); +const ffmpeg = require('fluent-ffmpeg'); +const { S3Client, ListObjectsV2Command, GetObjectCommand } = require('@aws-sdk/client-s3'); + +dotenv.config(); + +const app = express(); +const PORT = process.env.PORT || 3000; + +app.use(cors()); +app.use(express.json()); +app.use(express.static('public')); + +// Configure AWS S3 Client +const s3Client = new S3Client({ + region: process.env.AWS_REGION || 'us-east-1', + endpoint: process.env.S3_ENDPOINT, + forcePathStyle: process.env.S3_FORCE_PATH_STYLE === 'true', + credentials: { + accessKeyId: process.env.AWS_ACCESS_KEY_ID, + secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY + } +}); + +const BUCKET_NAME = process.env.S3_BUCKET_NAME; + +// Endpoint to list videos in the bucket +app.get('/api/videos', async (req, res) => { + try { + if (!BUCKET_NAME) { + return res.status(500).json({ error: 'S3_BUCKET_NAME not configured' }); + } + const command = new ListObjectsV2Command({ + Bucket: BUCKET_NAME, + }); + + const response = await s3Client.send(command); + + // Filter out non-mp4 files for this boilerplate + const videos = (response.Contents || []) + .map(item => item.Key) + .filter(key => key && key.toLowerCase().endsWith('.mp4')); + + res.json({ videos }); + } catch (error) { + console.error('Error fetching videos:', error); + res.status(500).json({ error: 'Failed to fetch videos from S3', detail: error.message }); + } +}); + +// Endpoint to transcode S3 video streaming to HLS +app.post('/api/transcode', async (req, res) => { + const { key } = req.body; + + if (!key) { + return res.status(400).json({ error: 'Video key is required' }); + } + + try { + const safeKeyName = key.replace(/[^a-zA-Z0-9_\-]/g, ''); + const outputDir = path.join(__dirname, 'public', 'hls', safeKeyName); + const m3u8Path = path.join(outputDir, 'index.m3u8'); + const hlsUrl = `/hls/${safeKeyName}/index.m3u8`; + + // If it already exists, just return the URL + if (fs.existsSync(m3u8Path)) { + return res.json({ message: 'Already transcoded', hlsUrl }); + } + + // Create output directory if it doesn't exist + fs.mkdirSync(outputDir, { recursive: true }); + + // Get S3 stream + const command = new GetObjectCommand({ + Bucket: BUCKET_NAME, + Key: key + }); + + const response = await s3Client.send(command); + const s3Stream = response.Body; + + // Triggers fluent-ffmpeg to transcode to HLS + console.log(`Starting transcoding for ${key}`); + + ffmpeg(s3Stream) + // Use hardware acceleration if available (optional for boilerplate) + .outputOptions([ + '-codec:v copy', // Use copy if it's already h264, else change to libx264 + '-codec:a aac', + '-hls_time 10', // 10 second segments + '-hls_list_size 0', // keep all segments in playlist + '-f hls' + ]) + .output(m3u8Path) + .on('end', () => { + console.log(`Finished transcoding ${key} to HLS`); + }) + .on('error', (err) => { + console.error(`Error transcoding ${key}:`, err); + }) + .run(); + + // Return immediately so the client can start polling or waiting + res.json({ message: 'Transcoding started', hlsUrl }); + + } catch (error) { + console.error('Error in transcode:', error); + res.status(500).json({ error: 'Failed to initiate transcoding', detail: error.message }); + } +}); + +// Status check for HLS playlist availability +app.get('/api/status', (req, res) => { + const { key } = req.query; + if (!key) return res.status(400).json({ error: 'Key is required' }); + + const safeKeyName = key.replace(/[^a-zA-Z0-9_\-]/g, ''); + const m3u8Path = path.join(__dirname, 'public', 'hls', safeKeyName, 'index.m3u8'); + + // Check if the playlist file exists + if (fs.existsSync(m3u8Path)) { + res.json({ ready: true, hlsUrl: `/hls/${safeKeyName}/index.m3u8` }); + } else { + res.json({ ready: false }); + } +}); + +app.listen(PORT, () => { + console.log(`Server running on http://localhost:${PORT}`); +});