引入m3u8

This commit is contained in:
CN-JS-HuiBai
2026-04-04 01:02:24 +08:00
parent f3733ef8ef
commit b3f5053482
3 changed files with 219 additions and 109 deletions

156
server.js
View File

@@ -446,6 +446,162 @@ app.post('/api/stop-transcode', (req, res) => {
}
});
const HLS_SEGMENT_TIME = 6;
const waitForSegment = async (hlsDir, segIndex, timeoutMs = 45000) => {
const start = Date.now();
const segPath = path.join(hlsDir, `segment_${segIndex}.ts`);
const m3u8Path = path.join(hlsDir, `temp.m3u8`);
while (Date.now() - start < timeoutMs) {
if (fs.existsSync(m3u8Path)) {
const m3u8Content = fs.readFileSync(m3u8Path, 'utf8');
if (m3u8Content.includes(`segment_${segIndex}.ts`)) {
return true;
}
if (m3u8Content.includes(`#EXT-X-ENDLIST`)) {
if (fs.existsSync(segPath)) return true;
return false;
}
}
await new Promise(r => setTimeout(r, 200));
}
return false;
};
app.get('/api/hls/playlist.m3u8', async (req, res) => {
const bucket = req.query.bucket;
const key = req.query.key;
if (!bucket || !key) return res.status(400).send('Bad Request');
const safeKeySegments = key.split('/').map(segment => segment.replace(/[^a-zA-Z0-9_\-]/g, '_'));
const safeBucket = bucket.replace(/[^a-zA-Z0-9_\-]/g, '_');
const tmpInputPath = path.join(os.tmpdir(), `s3-input-${safeBucket}-${safeKeySegments.join('-')}.tmp`);
const auth = extractS3Credentials(req);
const s3Client = createS3Client(auth);
let totalBytes = 0;
if (!fs.existsSync(tmpInputPath)) {
try {
const command = new GetObjectCommand({ Bucket: bucket, Key: key });
const response = await s3Client.send(command);
totalBytes = response.ContentLength;
await new Promise((resolve, reject) => {
const writeStream = fs.createWriteStream(tmpInputPath);
response.Body.pipe(writeStream);
writeStream.on('error', reject);
writeStream.on('finish', resolve);
});
} catch(err) {
return res.status(500).send('S3 Download Failed');
}
}
let duration = 0;
try {
const metadata = await probeFile(tmpInputPath);
duration = parseFloat(metadata.format?.duration || 0);
} catch(err) {}
if (duration <= 0) duration = 3600;
const totalSegments = Math.ceil(duration / HLS_SEGMENT_TIME);
let m3u8 = `#EXTM3U\n#EXT-X-VERSION:3\n#EXT-X-TARGETDURATION:${HLS_SEGMENT_TIME}\n#EXT-X-MEDIA-SEQUENCE:0\n#EXT-X-PLAYLIST-TYPE:VOD\n`;
for (let i = 0; i < totalSegments; i++) {
let segDur = HLS_SEGMENT_TIME;
if (i === totalSegments - 1 && duration % HLS_SEGMENT_TIME !== 0) {
segDur = (duration % HLS_SEGMENT_TIME) || HLS_SEGMENT_TIME;
}
m3u8 += `#EXTINF:${segDur.toFixed(6)},\nsegment.ts?bucket=${encodeURIComponent(bucket)}&key=${encodeURIComponent(key)}&seg=${i}&encoder=${req.query.encoder||'h264_rkmpp'}&decoder=${req.query.decoder||'auto'}\n`;
}
m3u8 += `#EXT-X-ENDLIST\n`;
res.setHeader('Content-Type', 'application/vnd.apple.mpegurl');
res.setHeader('Cache-Control', 'no-cache');
res.send(m3u8);
});
const hlsProcesses = new Map();
app.get('/api/hls/segment.ts', async (req, res) => {
const bucket = req.query.bucket;
const key = req.query.key;
const seg = parseInt(req.query.seg || '0');
const requestedEncoder = req.query.encoder || 'h264_rkmpp';
const requestedDecoder = req.query.decoder || 'auto';
if (!bucket || !key || isNaN(seg)) return res.status(400).send('Bad Request');
const safeKeySegments = key.split('/').map(segment => segment.replace(/[^a-zA-Z0-9_\-]/g, '_'));
const safeBucket = bucket.replace(/[^a-zA-Z0-9_\-]/g, '_');
const tmpInputPath = path.join(os.tmpdir(), `s3-input-${safeBucket}-${safeKeySegments.join('-')}.tmp`);
const progressKey = safeKeySegments.join('/');
const hlsDir = path.join(os.tmpdir(), `hls-${safeBucket}-${progressKey}`);
if (!fs.existsSync(hlsDir)) fs.mkdirSync(hlsDir, { recursive: true });
const targetSegPath = path.join(hlsDir, `segment_${seg}.ts`);
let currentProcess = hlsProcesses.get(progressKey);
const needsNewProcess = !currentProcess || (!fs.existsSync(targetSegPath) && Math.abs((currentProcess.currentSeg || 0) - seg) > 2);
if (needsNewProcess) {
if (currentProcess && currentProcess.command) {
try { currentProcess.command.kill('SIGKILL'); } catch(e){}
}
const startTime = Math.max(0, seg * HLS_SEGMENT_TIME);
let sourceMetadata = null;
try { sourceMetadata = await probeFile(tmpInputPath); } catch(e){}
const encoderName = availableEncoderValues.has(requestedEncoder) ? requestedEncoder : 'h264_rkmpp';
const decoderName = availableDecoderValues.has(requestedDecoder) ? requestedDecoder : 'auto';
const m3u8Path = path.join(hlsDir, `temp.m3u8`);
if (fs.existsSync(m3u8Path)) fs.unlinkSync(m3u8Path);
const ffmpegCommand = ffmpeg().input(tmpInputPath);
if (startTime > 0) ffmpegCommand.seekInput(startTime);
ffmpegCommand.videoCodec(encoderName).audioCodec('aac');
if (isVaapiCodec(encoderName)) {
ffmpegCommand.inputOptions(['-vaapi_device', '/dev/dri/renderD128']).videoFilters('format=nv12,hwupload');
}
const resolvedDecoderName = decoderName === 'auto' && isRkmppCodec(encoderName) ? getRkmppDecoderName(sourceMetadata) : decoderName;
if (resolvedDecoderName && resolvedDecoderName !== 'auto') ffmpegCommand.inputOptions(['-c:v', resolvedDecoderName]);
const segmentFilename = path.join(hlsDir, `segment_%d.ts`);
const hlsOptions = createFfmpegOptions(encoderName).concat([
'-f', 'hls',
'-hls_time', HLS_SEGMENT_TIME.toString(),
'-hls_list_size', '0',
'-hls_segment_filename', segmentFilename,
'-start_number', seg.toString()
]);
ffmpegCommand.outputOptions(hlsOptions).output(m3u8Path);
ffmpegCommand.on('error', (err) => {
console.error('HLS FFmpeg Error:', err.message);
});
ffmpegCommand.run();
currentProcess = { command: ffmpegCommand, currentSeg: seg };
hlsProcesses.set(progressKey, currentProcess);
}
const ready = await waitForSegment(hlsDir, seg);
if (!ready) {
return res.status(500).send('Segment generation timeout');
}
if (currentProcess) currentProcess.currentSeg = Math.max(currentProcess.currentSeg, seg);
res.setHeader('Content-Type', 'video/MP2T');
res.sendFile(targetSegPath);
});
app.get('/api/stream', async (req, res) => {
const bucket = req.query.bucket;
const key = req.query.key;