Skip to content

Commit

Permalink
Use MediaSource for unsupported formats
Browse files Browse the repository at this point in the history
  • Loading branch information
mifi committed Jan 4, 2024
1 parent 83c910a commit 7f32cdc
Show file tree
Hide file tree
Showing 12 changed files with 525 additions and 260 deletions.
4 changes: 2 additions & 2 deletions issues.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,9 +104,9 @@ If the output file name has special characters that get replaced by underscore (

# Known limitations

## Low quality / blurry playback and no audio
## Low quality / blurry playback

Some formats or codecs are not natively supported, so they will preview with low quality playback and no audio. You may convert these files to a supported codec from the File menu, see [#88](https://github.com/mifi/lossless-cut/issues/88).
Some formats or codecs are not natively supported, so they will play back with a lower quality. You may convert these files to a supported codec from the File menu, see [#88](https://github.com/mifi/lossless-cut/issues/88).

## MPEG TS / MTS

Expand Down
1 change: 0 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,6 @@
"morgan": "^1.10.0",
"semver": "^7.5.2",
"string-to-stream": "^1.1.1",
"strtok3": "^6.0.0",
"winston": "^3.8.1",
"yargs-parser": "^21.0.0"
},
Expand Down
129 changes: 82 additions & 47 deletions public/canvasPlayer.js
Original file line number Diff line number Diff line change
@@ -1,67 +1,102 @@
const strtok3 = require('strtok3');
const logger = require('./logger');
const { createMediaSourceProcess, readOneJpegFrame } = require('./ffmpeg');

const { getOneRawFrame, encodeLiveRawStream } = require('./ffmpeg');

function createMediaSourceStream({ path, videoStreamIndex, audioStreamIndex, seekTo, size, fps }) {
const abortController = new AbortController();
logger.info('Starting preview process', { videoStreamIndex, audioStreamIndex, seekTo });
const process = createMediaSourceProcess({ path, videoStreamIndex, audioStreamIndex, seekTo, size, fps });

let aborters = [];
abortController.signal.onabort = () => {
logger.info('Aborting preview process', { videoStreamIndex, audioStreamIndex, seekTo });
process.kill('SIGKILL');
};

async function command({ path, inWidth, inHeight, streamIndex, seekTo: commandedTime, onRawFrame, onJpegFrame, playing }) {
let process;
let aborted = false;
process.stdout.pause();

function killProcess() {
if (process) {
process.kill();
process = undefined;
}
async function readChunk() {
return new Promise((resolve, reject) => {
let cleanup;

const onClose = () => {
cleanup();
resolve(null);
};
const onData = (chunk) => {
process.stdout.pause();
cleanup();
resolve(chunk);
};
const onError = (err) => {
cleanup();
reject(err);
};
cleanup = () => {
process.stdout.off('data', onData);
process.stdout.off('error', onError);
process.stdout.off('close', onClose);
};

process.stdout.once('data', onData);
process.stdout.once('error', onError);
process.stdout.once('close', onClose);

process.stdout.resume();
});
}

function abort() {
aborted = true;
killProcess();
aborters = aborters.filter(((aborter) => aborter !== abort));
abortController.abort();
}
aborters.push(abort);

try {
if (playing) {
const { process: processIn, channels, width, height } = encodeLiveRawStream({ path, inWidth, inHeight, streamIndex, seekTo: commandedTime });
process = processIn;

// process.stderr.on('data', data => console.log(data.toString('utf-8')));
let stderr = Buffer.alloc(0);
process.stderr?.on('data', (chunk) => {
stderr = Buffer.concat([stderr, chunk]);
});

const tokenizer = await strtok3.fromStream(process.stdout);
if (aborted) return;

const size = width * height * channels;
const rgbaImage = Buffer.allocUnsafe(size);
(async () => {
try {
await process;
} catch (err) {
if (err instanceof Error && err.name === 'AbortError') {
return;
}

while (!aborted) {
// eslint-disable-next-line no-await-in-loop
await tokenizer.readBuffer(rgbaImage, { length: size });
if (aborted) return;
// eslint-disable-next-line no-await-in-loop
await onRawFrame(rgbaImage, width, height);
if (!err.killed) {
console.warn(err.message);
console.warn(stderr.toString('utf-8'));
}
} else {
const { process: processIn, width, height } = getOneRawFrame({ path, inWidth, inHeight, streamIndex, seekTo: commandedTime, outSize: 1000 });
process = processIn;
const { stdout: jpegImage } = await process;
if (aborted) return;
onJpegFrame(jpegImage, width, height);
}
} catch (err) {
if (!err.killed) console.warn(err.message);
} finally {
killProcess();
}
})();

return { abort, readChunk };
}

function abortAll() {
aborters.forEach((aborter) => aborter());
function readOneJpegFrameWrapper({ path, seekTo, videoStreamIndex }) {
const abortController = new AbortController();
const process = readOneJpegFrame({ path, seekTo, videoStreamIndex });

abortController.signal.onabort = () => process.kill('SIGKILL');

function abort() {
abortController.abort();
}

const promise = (async () => {
try {
const { stdout } = await process;
return stdout;
} catch (err) {
logger.error('renderOneJpegFrame', err.shortMessage);
throw new Error('Failed to render JPEG frame');
}
})();

return { promise, abort };
}


module.exports = {
command,
abortAll,
createMediaSourceStream,
readOneJpegFrame: readOneJpegFrameWrapper,
};
100 changes: 54 additions & 46 deletions public/ffmpeg.js
Original file line number Diff line number Diff line change
Expand Up @@ -386,8 +386,8 @@ async function html5ify({ outPath, filePath: filePathArg, speed, hasAudio, hasVi
let audio;
if (hasAudio) {
if (speed === 'slowest') audio = 'hq';
else if (['slow-audio', 'fast-audio', 'fastest-audio'].includes(speed)) audio = 'lq';
else if (['fast-audio-remux', 'fastest-audio-remux'].includes(speed)) audio = 'copy';
else if (['slow-audio', 'fast-audio'].includes(speed)) audio = 'lq';
else if (['fast-audio-remux'].includes(speed)) audio = 'copy';
}

let video;
Expand Down Expand Up @@ -485,24 +485,7 @@ async function html5ify({ outPath, filePath: filePathArg, speed, hasAudio, hasVi
console.log(stdout);
}

function calcSize({ inWidth, inHeight, outSize }) {
const aspectRatio = inWidth / inHeight;

if (inWidth > inHeight) {
return {
newWidth: outSize,
newHeight: Math.floor(outSize / aspectRatio),
};
}
return {
newHeight: outSize,
newWidth: Math.floor(outSize * aspectRatio),
};
}

function getOneRawFrame({ path, inWidth, inHeight, seekTo, streamIndex, outSize }) {
const { newWidth, newHeight } = calcSize({ inWidth, inHeight, outSize });

function readOneJpegFrame({ path, seekTo, videoStreamIndex }) {
const args = [
'-hide_banner', '-loglevel', 'error',

Expand All @@ -512,8 +495,7 @@ function getOneRawFrame({ path, inWidth, inHeight, seekTo, streamIndex, outSize

'-i', path,

'-vf', `scale=${newWidth}:${newHeight}:flags=lanczos`,
'-map', `0:${streamIndex}`,
'-map', `0:${videoStreamIndex}`,
'-vcodec', 'mjpeg',

'-frames:v', '1',
Expand All @@ -524,44 +506,70 @@ function getOneRawFrame({ path, inWidth, inHeight, seekTo, streamIndex, outSize

// console.log(args);

return {
process: runFfmpegProcess(args, { encoding: 'buffer' }, { logCli: true }),
width: newWidth,
height: newHeight,
};
return runFfmpegProcess(args, { encoding: 'buffer' }, { logCli: true });
}

function encodeLiveRawStream({ path, inWidth, inHeight, seekTo, streamIndex, fps = 25 }) {
const { newWidth, newHeight } = calcSize({ inWidth, inHeight, outSize: 320 });
const enableLog = false;
const encode = true;

function createMediaSourceProcess({ path, videoStreamIndex, audioStreamIndex, seekTo, size, fps }) {
function getVideoFilters() {
if (videoStreamIndex == null) return [];

const filters = [];
if (fps != null) filters.push(`fps=${fps}`);
if (size != null) filters.push(`scale=${size}:${size}:flags=lanczos:force_original_aspect_ratio=decrease`);
if (filters.length === 0) return [];
return ['-vf', filters.join(',')];
}

// https://stackoverflow.com/questions/16658873/how-to-minimize-the-delay-in-a-live-streaming-with-ffmpeg
// https://unix.stackexchange.com/questions/25372/turn-off-buffering-in-pipe
const args = [
'-hide_banner', '-loglevel', 'panic',
'-hide_banner',
...(enableLog ? [] : ['-loglevel', 'error']),

// https://stackoverflow.com/questions/30868854/flush-latency-issue-with-fragmented-mp4-creation-in-ffmpeg
'-fflags', '+nobuffer+flush_packets+discardcorrupt',
'-avioflags', 'direct',
// '-flags', 'low_delay', // this seems to ironically give a *higher* delay
'-flush_packets', '1',

'-re',
'-vsync', 'passthrough',

'-ss', seekTo,

'-noautorotate',

'-i', path,

'-vf', `fps=${fps},scale=${newWidth}:${newHeight}:flags=lanczos`,
'-map', `0:${streamIndex}`,
'-vcodec', 'rawvideo',
'-pix_fmt', 'rgba',
...(videoStreamIndex != null ? ['-map', `0:${videoStreamIndex}`] : ['-vn']),

'-f', 'image2pipe',
'-',
...(audioStreamIndex != null ? ['-map', `0:${audioStreamIndex}`] : ['-an']),

...(encode ? [
...(videoStreamIndex != null ? [
...getVideoFilters(),

'-pix_fmt', 'yuv420p', '-c:v', 'libx264', '-preset', 'ultrafast', '-tune', 'zerolatency', '-crf', '10',
'-g', '1', // reduces latency and buffering
] : []),

...(audioStreamIndex != null ? [
'-ac', '2', '-c:a', 'aac', '-b:a', '128k',
] : []),

// May alternatively use webm/vp8 https://stackoverflow.com/questions/24152810/encoding-ffmpeg-to-mpeg-dash-or-webm-with-keyframe-clusters-for-mediasource
] : [
'-c', 'copy',
]),

'-f', 'mp4', '-movflags', '+frag_keyframe+empty_moov+default_base_moof', '-',
];

// console.log(args);
if (enableLog) console.log(getFfCommandLine('ffmpeg', args));

return {
process: runFfmpegProcess(args, { encoding: null, buffer: false }, { logCli: true }),
width: newWidth,
height: newHeight,
channels: 4,
};
return execa(getFfmpegPath(), args, { encoding: null, buffer: false, stderr: enableLog ? 'inherit' : 'pipe' });
}

// Don't pass complex objects over the bridge
Expand All @@ -583,8 +591,8 @@ module.exports = {
getFfCommandLine,
html5ify,
getDuration,
getOneRawFrame,
encodeLiveRawStream,
readOneJpegFrame,
blackDetect,
silenceDetect,
createMediaSourceProcess,
};
Loading

0 comments on commit 7f32cdc

Please sign in to comment.