修改了前端界面,增加了编码方式和硬件编码器的选择,并添加了播放按钮和转码进度显示。同时在后端引入了fluent-ffmpeg库来处理视频转码,并使用WebSocket实时传输转码进度信息到前端。
This commit is contained in:
@@ -9,8 +9,6 @@
|
|||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;600;800&display=swap" rel="stylesheet">
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;600;800&display=swap" rel="stylesheet">
|
||||||
<link rel="stylesheet" href="/css/style.css">
|
<link rel="stylesheet" href="/css/style.css">
|
||||||
<!-- hls.js for HLS streaming support -->
|
|
||||||
<script src="https://cdn.jsdelivr.net/npm/hls.js@latest"></script>
|
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="background-effects">
|
<div class="background-effects">
|
||||||
@@ -21,7 +19,7 @@
|
|||||||
<div class="container">
|
<div class="container">
|
||||||
<header>
|
<header>
|
||||||
<h1>S3 Media <span>Transcoder</span></h1>
|
<h1>S3 Media <span>Transcoder</span></h1>
|
||||||
<p>Seamlessly stream videos from AWS S3 dynamically via FFmpeg HLS Transcoding</p>
|
<p>Seamlessly transcode videos from AWS S3 to MP4 for browser playback</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main class="dashboard">
|
<main class="dashboard">
|
||||||
|
|||||||
@@ -246,7 +246,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
if (data.error) throw new Error(data.error);
|
if (data.error) throw new Error(data.error);
|
||||||
|
|
||||||
if (!wsConnected) {
|
if (!wsConnected) {
|
||||||
pollForHlsReady(key, data.hlsUrl);
|
pollForHlsReady(key, data.mp4Url);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
@@ -254,7 +254,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Poll the backend to check if the generated m3u8 file is accessible
|
// Poll the backend to check if the generated MP4 file is accessible
|
||||||
const pollForHlsReady = (key, hlsUrl) => {
|
const pollForHlsReady = (key, hlsUrl) => {
|
||||||
let attempts = 0;
|
let attempts = 0;
|
||||||
const maxAttempts = 120; // 60 seconds max wait for first segment
|
const maxAttempts = 120; // 60 seconds max wait for first segment
|
||||||
@@ -291,27 +291,15 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Initialize HLS Player
|
// Initialize MP4 Player
|
||||||
const playHlsStream = (url) => {
|
const playHlsStream = (url) => {
|
||||||
transcodingOverlay.classList.add('hidden');
|
transcodingOverlay.classList.add('hidden');
|
||||||
videoPlayer.classList.remove('hidden');
|
videoPlayer.classList.remove('hidden');
|
||||||
playBtn.classList.add('hidden');
|
playBtn.classList.add('hidden');
|
||||||
resetProgress();
|
resetProgress();
|
||||||
|
|
||||||
if (Hls.isSupported()) {
|
|
||||||
const hls = new Hls();
|
|
||||||
hls.loadSource(url);
|
|
||||||
hls.attachMedia(videoPlayer);
|
|
||||||
hls.on(Hls.Events.MANIFEST_PARSED, () => {
|
|
||||||
if (playBtn) {
|
|
||||||
playBtn.disabled = false;
|
|
||||||
playBtn.textContent = 'Play';
|
|
||||||
playBtn.classList.remove('hidden');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} else if (videoPlayer.canPlayType('application/vnd.apple.mpegurl')) {
|
|
||||||
// Safari uses native HLS
|
|
||||||
videoPlayer.src = url;
|
videoPlayer.src = url;
|
||||||
|
videoPlayer.load();
|
||||||
videoPlayer.addEventListener('loadedmetadata', () => {
|
videoPlayer.addEventListener('loadedmetadata', () => {
|
||||||
if (playBtn) {
|
if (playBtn) {
|
||||||
playBtn.disabled = false;
|
playBtn.disabled = false;
|
||||||
@@ -319,7 +307,6 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
playBtn.classList.remove('hidden');
|
playBtn.classList.remove('hidden');
|
||||||
}
|
}
|
||||||
}, { once: true });
|
}, { once: true });
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Bind events
|
// Bind events
|
||||||
|
|||||||
61
server.js
61
server.js
@@ -77,8 +77,8 @@ wss.on('connection', (ws) => {
|
|||||||
const currentProgress = progressMap[message.key];
|
const currentProgress = progressMap[message.key];
|
||||||
if (currentProgress) {
|
if (currentProgress) {
|
||||||
ws.send(JSON.stringify({ type: 'progress', key: message.key, progress: currentProgress }));
|
ws.send(JSON.stringify({ type: 'progress', key: message.key, progress: currentProgress }));
|
||||||
if (currentProgress.status === 'finished' && currentProgress.hlsUrl) {
|
if (currentProgress.status === 'finished' && currentProgress.mp4Url) {
|
||||||
ws.send(JSON.stringify({ type: 'ready', key: message.key, hlsUrl: currentProgress.hlsUrl }));
|
ws.send(JSON.stringify({ type: 'ready', key: message.key, mp4Url: currentProgress.mp4Url }));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -96,11 +96,18 @@ app.get('/api/videos', async (req, res) => {
|
|||||||
if (!BUCKET_NAME) {
|
if (!BUCKET_NAME) {
|
||||||
return res.status(500).json({ error: 'S3_BUCKET_NAME not configured' });
|
return res.status(500).json({ error: 'S3_BUCKET_NAME not configured' });
|
||||||
}
|
}
|
||||||
|
const allObjects = [];
|
||||||
|
let continuationToken;
|
||||||
|
|
||||||
|
do {
|
||||||
const command = new ListObjectsV2Command({
|
const command = new ListObjectsV2Command({
|
||||||
Bucket: BUCKET_NAME,
|
Bucket: BUCKET_NAME,
|
||||||
|
ContinuationToken: continuationToken,
|
||||||
});
|
});
|
||||||
|
|
||||||
const response = await s3Client.send(command);
|
const response = await s3Client.send(command);
|
||||||
|
allObjects.push(...(response.Contents || []));
|
||||||
|
continuationToken = response.IsTruncated ? response.NextContinuationToken : undefined;
|
||||||
|
} while (continuationToken);
|
||||||
|
|
||||||
// Filter for a broader set of common video formats
|
// Filter for a broader set of common video formats
|
||||||
const videoExtensions = [
|
const videoExtensions = [
|
||||||
@@ -124,7 +131,7 @@ app.get('/api/videos', async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Endpoint to transcode S3 video streaming to HLS
|
// Endpoint to transcode S3 video streaming to MP4
|
||||||
app.post('/api/transcode', async (req, res) => {
|
app.post('/api/transcode', async (req, res) => {
|
||||||
const { key, codec, encoder } = req.body;
|
const { key, codec, encoder } = req.body;
|
||||||
|
|
||||||
@@ -144,15 +151,15 @@ app.post('/api/transcode', async (req, res) => {
|
|||||||
try {
|
try {
|
||||||
const safeKeySegments = key.split('/').map(segment => segment.replace(/[^a-zA-Z0-9_\-]/g, '_'));
|
const safeKeySegments = key.split('/').map(segment => segment.replace(/[^a-zA-Z0-9_\-]/g, '_'));
|
||||||
const progressKey = safeKeySegments.join('/');
|
const progressKey = safeKeySegments.join('/');
|
||||||
const outputDir = path.join(__dirname, 'public', 'hls', ...safeKeySegments);
|
const outputDir = path.join(__dirname, 'public', 'mp4', ...safeKeySegments);
|
||||||
const m3u8Path = path.join(outputDir, 'index.m3u8');
|
const mp4Path = path.join(outputDir, 'video.mp4');
|
||||||
const hlsUrl = `/hls/${progressKey}/index.m3u8`;
|
const mp4Url = `/mp4/${progressKey}/video.mp4`;
|
||||||
|
|
||||||
progressMap[progressKey] = { status: 'pending', percent: 0, details: 'Waiting for ffmpeg to start', hlsUrl };
|
progressMap[progressKey] = { status: 'pending', percent: 0, details: 'Waiting for ffmpeg to start', mp4Url };
|
||||||
|
|
||||||
// If it already exists, just return the URL
|
// If it already exists, just return the URL
|
||||||
if (fs.existsSync(m3u8Path)) {
|
if (fs.existsSync(mp4Path)) {
|
||||||
return res.json({ message: 'Already transcoded', hlsUrl });
|
return res.json({ message: 'Already transcoded', mp4Url });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create output directory if it doesn't exist
|
// Create output directory if it doesn't exist
|
||||||
@@ -167,7 +174,7 @@ app.post('/api/transcode', async (req, res) => {
|
|||||||
const response = await s3Client.send(command);
|
const response = await s3Client.send(command);
|
||||||
const s3Stream = response.Body;
|
const s3Stream = response.Body;
|
||||||
|
|
||||||
// Triggers fluent-ffmpeg to transcode to HLS
|
// Triggers fluent-ffmpeg to transcode to MP4
|
||||||
console.log(`Starting transcoding for ${key} with codec ${videoCodec}`);
|
console.log(`Starting transcoding for ${key} with codec ${videoCodec}`);
|
||||||
|
|
||||||
ffmpeg(s3Stream)
|
ffmpeg(s3Stream)
|
||||||
@@ -175,14 +182,10 @@ app.post('/api/transcode', async (req, res) => {
|
|||||||
.audioCodec('aac')
|
.audioCodec('aac')
|
||||||
.outputOptions([
|
.outputOptions([
|
||||||
'-preset fast',
|
'-preset fast',
|
||||||
'-crf 23',
|
'-crf 23'
|
||||||
'-hls_time 6',
|
|
||||||
'-hls_list_size 0',
|
|
||||||
'-hls_allow_cache 1',
|
|
||||||
'-hls_flags independent_segments',
|
|
||||||
'-f hls'
|
|
||||||
])
|
])
|
||||||
.output(m3u8Path)
|
.format('mp4')
|
||||||
|
.output(mp4Path)
|
||||||
.on('progress', (progress) => {
|
.on('progress', (progress) => {
|
||||||
const progressState = {
|
const progressState = {
|
||||||
status: 'transcoding',
|
status: 'transcoding',
|
||||||
@@ -192,28 +195,28 @@ app.post('/api/transcode', async (req, res) => {
|
|||||||
bitrate: progress.currentKbps || null,
|
bitrate: progress.currentKbps || null,
|
||||||
timemark: progress.timemark || null,
|
timemark: progress.timemark || null,
|
||||||
details: `Transcoding... ${Math.min(Math.max(Math.round(progress.percent || 0), 0), 100)}%`,
|
details: `Transcoding... ${Math.min(Math.max(Math.round(progress.percent || 0), 0), 100)}%`,
|
||||||
hlsUrl
|
mp4Url
|
||||||
};
|
};
|
||||||
progressMap[progressKey] = progressState;
|
progressMap[progressKey] = progressState;
|
||||||
broadcastWs(progressKey, { type: 'progress', key: progressKey, progress: progressState });
|
broadcastWs(progressKey, { type: 'progress', key: progressKey, progress: progressState });
|
||||||
})
|
})
|
||||||
.on('end', () => {
|
.on('end', () => {
|
||||||
console.log(`Finished transcoding ${key} to HLS`);
|
console.log(`Finished transcoding ${key} to MP4`);
|
||||||
const progressState = { status: 'finished', percent: 100, details: 'Transcoding complete', hlsUrl };
|
const progressState = { status: 'finished', percent: 100, details: 'Transcoding complete', mp4Url };
|
||||||
progressMap[progressKey] = progressState;
|
progressMap[progressKey] = progressState;
|
||||||
broadcastWs(progressKey, { type: 'progress', key: progressKey, progress: progressState });
|
broadcastWs(progressKey, { type: 'progress', key: progressKey, progress: progressState });
|
||||||
broadcastWs(progressKey, { type: 'ready', key: progressKey, hlsUrl });
|
broadcastWs(progressKey, { type: 'ready', key: progressKey, mp4Url });
|
||||||
})
|
})
|
||||||
.on('error', (err) => {
|
.on('error', (err) => {
|
||||||
console.error(`Error transcoding ${key}:`, err);
|
console.error(`Error transcoding ${key}:`, err);
|
||||||
const failedState = { status: 'failed', percent: progressMap[progressKey]?.percent || 0, details: err.message || 'Transcoding failed', hlsUrl };
|
const failedState = { status: 'failed', percent: progressMap[progressKey]?.percent || 0, details: err.message || 'Transcoding failed', mp4Url };
|
||||||
progressMap[progressKey] = failedState;
|
progressMap[progressKey] = failedState;
|
||||||
broadcastWs(progressKey, { type: 'progress', key: progressKey, progress: failedState });
|
broadcastWs(progressKey, { type: 'progress', key: progressKey, progress: failedState });
|
||||||
})
|
})
|
||||||
.run();
|
.run();
|
||||||
|
|
||||||
// Return immediately so the client can start polling or waiting
|
// Return immediately so the client can start polling or waiting
|
||||||
res.json({ message: 'Transcoding started', hlsUrl });
|
res.json({ message: 'Transcoding started', mp4Url });
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error in transcode:', error);
|
console.error('Error in transcode:', error);
|
||||||
@@ -221,19 +224,19 @@ app.post('/api/transcode', async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Status check for HLS playlist availability
|
// Status check for MP4 availability
|
||||||
app.get('/api/status', (req, res) => {
|
app.get('/api/status', (req, res) => {
|
||||||
const { key } = req.query;
|
const { key } = req.query;
|
||||||
if (!key) return res.status(400).json({ error: 'Key is required' });
|
if (!key) return res.status(400).json({ error: 'Key is required' });
|
||||||
|
|
||||||
const safeKeySegments = key.split('/').map(segment => segment.replace(/[^a-zA-Z0-9_\-]/g, '_'));
|
const safeKeySegments = key.split('/').map(segment => segment.replace(/[^a-zA-Z0-9_\-]/g, '_'));
|
||||||
const progressKey = safeKeySegments.join('/');
|
const progressKey = safeKeySegments.join('/');
|
||||||
const m3u8Path = path.join(__dirname, 'public', 'hls', ...safeKeySegments, 'index.m3u8');
|
const mp4Path = path.join(__dirname, 'public', 'mp4', ...safeKeySegments, 'video.mp4');
|
||||||
const progress = progressMap[progressKey] || null;
|
const progress = progressMap[progressKey] || null;
|
||||||
|
|
||||||
// Check if the playlist file exists
|
// Check if the MP4 file exists
|
||||||
if (fs.existsSync(m3u8Path)) {
|
if (fs.existsSync(mp4Path)) {
|
||||||
res.json({ ready: true, hlsUrl: `/hls/${safeKeySegments.join('/')}/index.m3u8`, progress });
|
res.json({ ready: true, mp4Url: `/mp4/${safeKeySegments.join('/')}/video.mp4`, progress });
|
||||||
} else {
|
} else {
|
||||||
res.json({ ready: false, progress });
|
res.json({ ready: false, progress });
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user