新增编码方式选择
This commit is contained in:
@@ -113,6 +113,12 @@ header p {
|
|||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.video-list-section,
|
||||||
|
.player-section,
|
||||||
|
.folder-header {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.glass-panel {
|
.glass-panel {
|
||||||
background: var(--panel-bg);
|
background: var(--panel-bg);
|
||||||
backdrop-filter: blur(16px);
|
backdrop-filter: blur(16px);
|
||||||
@@ -137,6 +143,32 @@ header p {
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.codec-panel {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.codec-panel label {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.codec-panel select {
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
color: var(--text-primary);
|
||||||
|
border: 1px solid var(--panel-border);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
min-width: 110px;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.codec-panel select:focus {
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
.icon-btn {
|
.icon-btn {
|
||||||
background: none;
|
background: none;
|
||||||
border: none;
|
border: none;
|
||||||
@@ -159,7 +191,6 @@ header p {
|
|||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
max-height: 500px;
|
max-height: 500px;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
overflow-x: auto;
|
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -212,15 +243,16 @@ header p {
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
max-width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.video-title {
|
.video-title {
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
font-size: 0.95rem;
|
font-size: 0.95rem;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
overflow-x: auto;
|
overflow: hidden;
|
||||||
overflow-y: hidden;
|
text-overflow: ellipsis;
|
||||||
text-overflow: clip;
|
max-width: 100%;
|
||||||
padding-bottom: 0.2rem;
|
padding-bottom: 0.2rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -32,6 +32,13 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/></svg>
|
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/></svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="codec-panel">
|
||||||
|
<label for="codec-select">编码方式:</label>
|
||||||
|
<select id="codec-select">
|
||||||
|
<option value="h264">H264</option>
|
||||||
|
<option value="h265">H265</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
<div id="loading-spinner" class="spinner-container">
|
<div id="loading-spinner" class="spinner-container">
|
||||||
<div class="spinner"></div>
|
<div class="spinner"></div>
|
||||||
<p>Fetching S3 Objects...</p>
|
<p>Fetching S3 Objects...</p>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
const videoListEl = document.getElementById('video-list');
|
const videoListEl = document.getElementById('video-list');
|
||||||
const loadingSpinner = document.getElementById('loading-spinner');
|
const loadingSpinner = document.getElementById('loading-spinner');
|
||||||
const refreshBtn = document.getElementById('refresh-btn');
|
const refreshBtn = document.getElementById('refresh-btn');
|
||||||
|
const codecSelect = document.getElementById('codec-select');
|
||||||
const playerOverlay = document.getElementById('player-overlay');
|
const playerOverlay = document.getElementById('player-overlay');
|
||||||
const transcodingOverlay = document.getElementById('transcoding-overlay');
|
const transcodingOverlay = document.getElementById('transcoding-overlay');
|
||||||
const videoPlayer = document.getElementById('video-player');
|
const videoPlayer = document.getElementById('video-player');
|
||||||
@@ -30,27 +31,25 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build a tree structure from S3 keys
|
// Build a tree structure from S3 keys, preserving original object storage directories
|
||||||
const tree = {};
|
const tree = {};
|
||||||
data.videos.forEach(key => {
|
data.videos.forEach(key => {
|
||||||
const parts = key.split('/');
|
const parts = key.split('/');
|
||||||
let current = tree;
|
let current = tree;
|
||||||
for (let i = 0; i < parts.length; i++) {
|
parts.forEach((part, index) => {
|
||||||
const part = parts[i];
|
|
||||||
if (!current[part]) {
|
if (!current[part]) {
|
||||||
current[part] = (i === parts.length - 1) ? key : {};
|
current[part] = {};
|
||||||
}
|
}
|
||||||
if (i < parts.length - 1) {
|
|
||||||
|
if (index === parts.length - 1) {
|
||||||
|
current[part].__file = key;
|
||||||
|
}
|
||||||
|
|
||||||
current = current[part];
|
current = current[part];
|
||||||
}
|
});
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Recursive function to render the tree
|
const createFileItem = (name, key) => {
|
||||||
const renderTree = (node, container) => {
|
|
||||||
for (const [name, value] of Object.entries(node)) {
|
|
||||||
if (typeof value === 'string') {
|
|
||||||
// It's a file
|
|
||||||
const li = document.createElement('li');
|
const li = document.createElement('li');
|
||||||
li.className = 'video-item file-item';
|
li.className = 'video-item file-item';
|
||||||
const ext = name.split('.').pop().toUpperCase();
|
const ext = name.split('.').pop().toUpperCase();
|
||||||
@@ -59,17 +58,27 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m22 8-6 4 6 4V8Z"/><rect width="14" height="12" x="2" y="6" rx="2" ry="2"/></svg>
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m22 8-6 4 6 4V8Z"/><rect width="14" height="12" x="2" y="6" rx="2" ry="2"/></svg>
|
||||||
</div>
|
</div>
|
||||||
<div class="video-info">
|
<div class="video-info">
|
||||||
<div class="video-title" title="${value}">${name}</div>
|
<div class="video-title" title="${key}">${name}</div>
|
||||||
<div class="video-meta">Video / ${ext}</div>
|
<div class="video-meta">Video / ${ext}</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
li.addEventListener('click', (e) => {
|
li.addEventListener('click', (e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
selectVideo(value, li);
|
selectVideo(key, li);
|
||||||
});
|
});
|
||||||
container.appendChild(li);
|
return li;
|
||||||
} else {
|
};
|
||||||
// It's a folder
|
|
||||||
|
// Recursive function to render the tree
|
||||||
|
const renderTree = (node, container) => {
|
||||||
|
for (const [name, value] of Object.entries(node)) {
|
||||||
|
if (name === '__file') continue;
|
||||||
|
|
||||||
|
const childKeys = Object.keys(value).filter(key => key !== '__file');
|
||||||
|
const hasChildren = childKeys.length > 0;
|
||||||
|
const isFile = typeof value.__file === 'string';
|
||||||
|
|
||||||
|
if (hasChildren) {
|
||||||
const li = document.createElement('li');
|
const li = document.createElement('li');
|
||||||
li.className = 'folder-item';
|
li.className = 'folder-item';
|
||||||
|
|
||||||
@@ -95,9 +104,14 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
li.appendChild(folderHeader);
|
li.appendChild(folderHeader);
|
||||||
|
if (isFile) {
|
||||||
|
subListContainer.appendChild(createFileItem(name, value.__file));
|
||||||
|
}
|
||||||
renderTree(value, subListContainer);
|
renderTree(value, subListContainer);
|
||||||
li.appendChild(subListContainer);
|
li.appendChild(subListContainer);
|
||||||
container.appendChild(li);
|
container.appendChild(li);
|
||||||
|
} else if (isFile) {
|
||||||
|
container.appendChild(createFileItem(name, value.__file));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -127,10 +141,11 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
transcodingOverlay.classList.remove('hidden');
|
transcodingOverlay.classList.remove('hidden');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const codec = codecSelect?.value || 'h264';
|
||||||
const res = await fetch('/api/transcode', {
|
const res = await fetch('/api/transcode', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ key })
|
body: JSON.stringify({ key, codec })
|
||||||
});
|
});
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
|
|
||||||
|
|||||||
32
server.js
32
server.js
@@ -64,17 +64,20 @@ app.get('/api/videos', async (req, res) => {
|
|||||||
|
|
||||||
// Endpoint to transcode S3 video streaming to HLS
|
// Endpoint to transcode S3 video streaming to HLS
|
||||||
app.post('/api/transcode', async (req, res) => {
|
app.post('/api/transcode', async (req, res) => {
|
||||||
const { key } = req.body;
|
const { key, codec } = req.body;
|
||||||
|
|
||||||
if (!key) {
|
if (!key) {
|
||||||
return res.status(400).json({ error: 'Video key is required' });
|
return res.status(400).json({ error: 'Video key is required' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const safeCodec = codec === 'h265' ? 'h265' : 'h264';
|
||||||
|
const videoCodec = safeCodec === 'h265' ? 'libx265' : 'libx264';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const safeKeyName = key.replace(/[^a-zA-Z0-9_\-]/g, '');
|
const safeKeySegments = key.split('/').map(segment => segment.replace(/[^a-zA-Z0-9_\-]/g, '_'));
|
||||||
const outputDir = path.join(__dirname, 'public', 'hls', safeKeyName);
|
const outputDir = path.join(__dirname, 'public', 'hls', ...safeKeySegments);
|
||||||
const m3u8Path = path.join(outputDir, 'index.m3u8');
|
const m3u8Path = path.join(outputDir, 'index.m3u8');
|
||||||
const hlsUrl = `/hls/${safeKeyName}/index.m3u8`;
|
const hlsUrl = `/hls/${safeKeySegments.join('/')}/index.m3u8`;
|
||||||
|
|
||||||
// If it already exists, just return the URL
|
// If it already exists, just return the URL
|
||||||
if (fs.existsSync(m3u8Path)) {
|
if (fs.existsSync(m3u8Path)) {
|
||||||
@@ -94,15 +97,18 @@ app.post('/api/transcode', async (req, res) => {
|
|||||||
const s3Stream = response.Body;
|
const s3Stream = response.Body;
|
||||||
|
|
||||||
// Triggers fluent-ffmpeg to transcode to HLS
|
// Triggers fluent-ffmpeg to transcode to HLS
|
||||||
console.log(`Starting transcoding for ${key}`);
|
console.log(`Starting transcoding for ${key} with codec ${videoCodec}`);
|
||||||
|
|
||||||
ffmpeg(s3Stream)
|
ffmpeg(s3Stream)
|
||||||
// Use hardware acceleration if available (optional for boilerplate)
|
.videoCodec(videoCodec)
|
||||||
|
.audioCodec('aac')
|
||||||
.outputOptions([
|
.outputOptions([
|
||||||
'-codec:v copy', // Use copy if it's already h264, else change to libx264
|
'-preset fast',
|
||||||
'-codec:a aac',
|
'-crf 23',
|
||||||
'-hls_time 10', // 10 second segments
|
'-hls_time 6',
|
||||||
'-hls_list_size 0', // keep all segments in playlist
|
'-hls_list_size 0',
|
||||||
|
'-hls_allow_cache 1',
|
||||||
|
'-hls_flags independent_segments',
|
||||||
'-f hls'
|
'-f hls'
|
||||||
])
|
])
|
||||||
.output(m3u8Path)
|
.output(m3u8Path)
|
||||||
@@ -128,12 +134,12 @@ 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 safeKeyName = key.replace(/[^a-zA-Z0-9_\-]/g, '');
|
const safeKeySegments = key.split('/').map(segment => segment.replace(/[^a-zA-Z0-9_\-]/g, '_'));
|
||||||
const m3u8Path = path.join(__dirname, 'public', 'hls', safeKeyName, 'index.m3u8');
|
const m3u8Path = path.join(__dirname, 'public', 'hls', ...safeKeySegments, 'index.m3u8');
|
||||||
|
|
||||||
// Check if the playlist file exists
|
// Check if the playlist file exists
|
||||||
if (fs.existsSync(m3u8Path)) {
|
if (fs.existsSync(m3u8Path)) {
|
||||||
res.json({ ready: true, hlsUrl: `/hls/${safeKeyName}/index.m3u8` });
|
res.json({ ready: true, hlsUrl: `/hls/${safeKeySegments.join('/')}/index.m3u8` });
|
||||||
} else {
|
} else {
|
||||||
res.json({ ready: false });
|
res.json({ ready: false });
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user