|
|
@@ -76,11 +76,11 @@ function log(type, data) {
|
|
|
|
|
|
function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
|
|
|
|
|
|
-/** Probe container + codec + bitrate (used for stream-copy decision) */
|
|
|
+/** Probe container + codec + bitrate + resolution (used for stream-copy decision & 4K check) */
|
|
|
function probeContainer(videoPath) {
|
|
|
return new Promise((resolve) => {
|
|
|
ffmpeg.ffprobe(videoPath, (err, metadata) => {
|
|
|
- if (err) return resolve({ container: null, videoCodec: null, audioCodec: null, duration: null, bitrate: null });
|
|
|
+ if (err) return resolve({ container: null, videoCodec: null, audioCodec: null, duration: null, bitrate: null, width: null, height: null });
|
|
|
const streams = metadata?.streams || [];
|
|
|
const videoStream = streams.find(s => s.codec_type === 'video');
|
|
|
const audioStream = streams.find(s => s.codec_type === 'audio');
|
|
|
@@ -94,6 +94,8 @@ function probeContainer(videoPath) {
|
|
|
audioCodec: audioStream?.codec_name || null,
|
|
|
duration: metadata?.format?.duration || null,
|
|
|
bitrate,
|
|
|
+ width: videoStream?.width || null,
|
|
|
+ height: videoStream?.height || null,
|
|
|
});
|
|
|
});
|
|
|
});
|
|
|
@@ -112,32 +114,28 @@ function canStreamCopy(probe) {
|
|
|
}
|
|
|
|
|
|
/** Get encoder-specific ffmpeg output options */
|
|
|
-function getEncoderOptions(encoder, duration) {
|
|
|
+function getEncoderOptions(encoder, duration, maxResolution) {
|
|
|
const opts = [];
|
|
|
|
|
|
if (encoder === 'h264_nvenc') {
|
|
|
- // NVIDIA GPU encoder
|
|
|
opts.push(
|
|
|
'-c:v', 'h264_nvenc',
|
|
|
- '-preset', 'p4', // p1=fastest … p7=slowest
|
|
|
+ '-preset', 'p4',
|
|
|
'-rc', 'vbr', '-cq', '23',
|
|
|
'-gpu', '0',
|
|
|
);
|
|
|
} else if (encoder === 'h264_qsv') {
|
|
|
- // Intel Quick Sync Video
|
|
|
opts.push(
|
|
|
'-c:v', 'h264_qsv',
|
|
|
'-preset', 'fast',
|
|
|
'-global_quality', '23',
|
|
|
);
|
|
|
} else if (encoder === 'h264_videotoolbox') {
|
|
|
- // Apple VideoToolbox (macOS)
|
|
|
opts.push(
|
|
|
'-c:v', 'h264_videotoolbox',
|
|
|
'-realtime',
|
|
|
);
|
|
|
} else {
|
|
|
- // Default: software libx264
|
|
|
opts.push(
|
|
|
'-c:v', 'libx264',
|
|
|
'-preset', 'fast',
|
|
|
@@ -145,6 +143,11 @@ function getEncoderOptions(encoder, duration) {
|
|
|
);
|
|
|
}
|
|
|
|
|
|
+ // Scale filter: cap resolution at maxResolution (preserves aspect ratio)
|
|
|
+ if (maxResolution) {
|
|
|
+ opts.push('-vf', `scale='min(${maxResolution},iw)':-2`);
|
|
|
+ }
|
|
|
+
|
|
|
opts.push('-pix_fmt', 'yuv420p');
|
|
|
opts.push('-c:a', 'aac', '-b:a', '128k');
|
|
|
return opts;
|
|
|
@@ -185,7 +188,7 @@ function probeAndThumbnail(videoPath, outputDir) {
|
|
|
}
|
|
|
|
|
|
/** ── HLS Transcode ────────────────────────────────────────────────────── */
|
|
|
-function transcodeToHLS(videoPath, outputDir, assetId, duration, encoder) {
|
|
|
+function transcodeToHLS(videoPath, outputDir, assetId, duration, encoder, maxResolution) {
|
|
|
return new Promise((resolve, reject) => {
|
|
|
const hlsDir = path.join(outputDir, 'hls', assetId);
|
|
|
fs.mkdirSync(hlsDir, { recursive: true });
|
|
|
@@ -193,7 +196,7 @@ function transcodeToHLS(videoPath, outputDir, assetId, duration, encoder) {
|
|
|
|
|
|
const cmd = ffmpeg(videoPath);
|
|
|
|
|
|
- const encOpts = getEncoderOptions(encoder, duration);
|
|
|
+ const encOpts = getEncoderOptions(encoder, duration, maxResolution);
|
|
|
cmd.outputOptions([
|
|
|
// Explicit stream mapping: video + optional audio (safe for silent files)
|
|
|
'-map', '0:v',
|
|
|
@@ -277,7 +280,7 @@ function streamCopyToHLS(videoPath, outputDir, assetId, duration) {
|
|
|
/** ── Process one job ──────────────────────────────────────────────────── */
|
|
|
async function processJob(asset) {
|
|
|
const { id: assetId, filePath, transcodePaused } = asset;
|
|
|
- const videoPath = path.join(UPLOAD_DIR, filePath);
|
|
|
+ let videoPath = path.join(UPLOAD_DIR, filePath);
|
|
|
|
|
|
if (transcodePaused) {
|
|
|
log('paused', { assetId, reason: 'transcodePaused flag set, re-queuing' });
|
|
|
@@ -297,8 +300,9 @@ async function processJob(asset) {
|
|
|
}
|
|
|
|
|
|
try {
|
|
|
- // Phase 1: probe + thumbnail
|
|
|
await prisma.asset.update({ where: { id: assetId }, data: { transcodeStatus: 'PROCESSING', transcodeProgress: 0 } });
|
|
|
+
|
|
|
+ // Phase 1: probe + thumbnail
|
|
|
const probe = await probeContainer(videoPath);
|
|
|
const thumbResult = await probeAndThumbnail(videoPath, UPLOAD_DIR);
|
|
|
|
|
|
@@ -310,6 +314,29 @@ async function processJob(asset) {
|
|
|
return;
|
|
|
}
|
|
|
|
|
|
+ // ── 4K → 1080p handling ──────────────────────────────────────────────
|
|
|
+ // If video height > 1080px, back up the original 4K file and re-encode to 1080p
|
|
|
+ const is4K = (probe.height ?? 0) > 1080;
|
|
|
+ let maxResolution = null;
|
|
|
+ if (is4K) {
|
|
|
+ maxResolution = '1920x1080';
|
|
|
+ const originalBackupPath = videoPath + '.original';
|
|
|
+ try {
|
|
|
+ fs.copyFileSync(videoPath, originalBackupPath);
|
|
|
+ log('4k_backup', { assetId, originalPath: originalBackupPath, height: probe.height });
|
|
|
+ } catch (backupErr) {
|
|
|
+ log('4k_backup_failed', { assetId, error: backupErr.message });
|
|
|
+ }
|
|
|
+ // Update DB: mark original file path so it can be served as download
|
|
|
+ await prisma.asset.update({
|
|
|
+ where: { id: assetId },
|
|
|
+ data: {
|
|
|
+ originalFilePath: filePath + '.original',
|
|
|
+ maxResolution,
|
|
|
+ },
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
// Update DB with metadata
|
|
|
await prisma.asset.update({
|
|
|
where: { id: assetId },
|
|
|
@@ -323,14 +350,23 @@ async function processJob(asset) {
|
|
|
},
|
|
|
});
|
|
|
|
|
|
- // Phase 2: HLS — decide between stream-copy or re-encode
|
|
|
- const isStreamCopy = canStreamCopy(probe);
|
|
|
+ // Phase 2: HLS
|
|
|
+ // For 4K: always re-encode (can't stream-copy a scaled stream)
|
|
|
+ // For non-4K: use stream-copy if possible (faster)
|
|
|
+ const isStreamCopy = !is4K && canStreamCopy(probe);
|
|
|
if (isStreamCopy) {
|
|
|
log('stream_copy', { assetId, reason: `container=${probe.container} video=${probe.videoCodec} audio=${probe.audioCodec}` });
|
|
|
await streamCopyToHLS(videoPath, UPLOAD_DIR, assetId, thumbResult.duration);
|
|
|
} else {
|
|
|
- log('re_encode', { assetId, reason: `video=${probe?.videoCodec} audio=${probe?.audioCodec} container=${probe?.container}`, encoder: ENCODER });
|
|
|
- await transcodeToHLS(videoPath, UPLOAD_DIR, assetId, thumbResult.duration, ENCODER);
|
|
|
+ log('re_encode', {
|
|
|
+ assetId,
|
|
|
+ reason: is4K
|
|
|
+ ? `4K source detected (height=${probe.height}), forcing re-encode to ${maxResolution}`
|
|
|
+ : `video=${probe?.videoCodec} audio=${probe?.audioCodec} container=${probe?.container}`,
|
|
|
+ encoder: ENCODER,
|
|
|
+ maxResolution,
|
|
|
+ });
|
|
|
+ await transcodeToHLS(videoPath, UPLOAD_DIR, assetId, thumbResult.duration, ENCODER, maxResolution);
|
|
|
}
|
|
|
|
|
|
// Done
|
|
|
@@ -347,7 +383,7 @@ async function processJob(asset) {
|
|
|
bitrate: thumbResult.bitrate ?? null,
|
|
|
},
|
|
|
});
|
|
|
- log('done', { assetId });
|
|
|
+ log('done', { assetId, is4K, hasOriginalDownload: is4K });
|
|
|
} catch (err) {
|
|
|
log('error', { assetId, error: err.message });
|
|
|
try {
|