mirror of https://github.com/mifi/lossless-cut
Use MediaSource for unsupported formats
parent
83c910a0fd
commit
7f32cdca8a
@ -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 }) {
|
process.stdout.pause();
|
||||||
let process;
|
|
||||||
let aborted = false;
|
|
||||||
|
|
||||||
function killProcess() {
|
async function readChunk() {
|
||||||
if (process) {
|
return new Promise((resolve, reject) => {
|
||||||
process.kill();
|
let cleanup;
|
||||||
process = undefined;
|
|
||||||
}
|
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() {
|
function abort() {
|
||||||
aborted = true;
|
abortController.abort();
|
||||||
killProcess();
|
|
||||||
aborters = aborters.filter(((aborter) => aborter !== 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);
|
(async () => {
|
||||||
if (aborted) return;
|
try {
|
||||||
|
await process;
|
||||||
const size = width * height * channels;
|
} catch (err) {
|
||||||
const rgbaImage = Buffer.allocUnsafe(size);
|
if (err instanceof Error && err.name === 'AbortError') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
while (!aborted) {
|
if (!err.killed) {
|
||||||
// eslint-disable-next-line no-await-in-loop
|
console.warn(err.message);
|
||||||
await tokenizer.readBuffer(rgbaImage, { length: size });
|
console.warn(stderr.toString('utf-8'));
|
||||||
if (aborted) return;
|
|
||||||
// eslint-disable-next-line no-await-in-loop
|
|
||||||
await onRawFrame(rgbaImage, width, height);
|
|
||||||
}
|
}
|
||||||
} 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 {
|
return { abort, readChunk };
|
||||||
killProcess();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function abortAll() {
|
function readOneJpegFrameWrapper({ path, seekTo, videoStreamIndex }) {
|
||||||
aborters.forEach((aborter) => aborter());
|
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 = {
|
module.exports = {
|
||||||
command,
|
createMediaSourceStream,
|
||||||
abortAll,
|
readOneJpegFrame: readOneJpegFrameWrapper,
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,55 +0,0 @@
|
|||||||
import React, { memo, useEffect, useRef, useMemo } from 'react';
|
|
||||||
import { useDebounce } from 'use-debounce';
|
|
||||||
|
|
||||||
import CanvasPlayer from './CanvasPlayer';
|
|
||||||
|
|
||||||
const Canvas = memo(({ rotate, filePath, width, height, playerTime, streamIndex, commandedTime, playing, eventId }) => {
|
|
||||||
const canvasRef = useRef();
|
|
||||||
|
|
||||||
const canvasPlayer = useMemo(() => CanvasPlayer({ path: filePath, width, height, streamIndex, getCanvas: () => canvasRef.current }), [filePath, width, height, streamIndex]);
|
|
||||||
|
|
||||||
useEffect(() => () => {
|
|
||||||
canvasPlayer.terminate();
|
|
||||||
}, [canvasPlayer]);
|
|
||||||
|
|
||||||
const state = useMemo(() => {
|
|
||||||
if (playing) {
|
|
||||||
return { startTime: commandedTime, playing, eventId };
|
|
||||||
}
|
|
||||||
return { startTime: playerTime, playing, eventId };
|
|
||||||
}, [commandedTime, eventId, playerTime, playing]);
|
|
||||||
|
|
||||||
const [debouncedState, { cancel }] = useDebounce(state, 200, {
|
|
||||||
equalityFn: (a, b) => a.startTime === b.startTime && a.playing === b.playing && a.eventId === b.eventId,
|
|
||||||
});
|
|
||||||
|
|
||||||
/* useEffect(() => {
|
|
||||||
console.log('state', state);
|
|
||||||
}, [state]); */
|
|
||||||
|
|
||||||
useEffect(() => () => {
|
|
||||||
cancel();
|
|
||||||
}, [cancel]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
// console.log('debouncedState', debouncedState);
|
|
||||||
|
|
||||||
if (debouncedState.startTime == null) return;
|
|
||||||
|
|
||||||
if (debouncedState.playing) {
|
|
||||||
canvasPlayer.play(debouncedState.startTime);
|
|
||||||
} else {
|
|
||||||
canvasPlayer.pause(debouncedState.startTime);
|
|
||||||
}
|
|
||||||
}, [debouncedState, canvasPlayer]);
|
|
||||||
|
|
||||||
const canvasStyle = useMemo(() => ({ display: 'block', width: '100%', height: '100%', objectFit: 'contain', transform: rotate ? `rotate(${rotate}deg)` : undefined }), [rotate]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div style={{ width: '100%', height: '100%', left: 0, right: 0, top: 0, bottom: 0, position: 'absolute', overflow: 'hidden', background: 'black' }}>
|
|
||||||
<canvas ref={canvasRef} style={canvasStyle} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
export default Canvas;
|
|
||||||
@ -1,59 +0,0 @@
|
|||||||
const remote = window.require('@electron/remote');
|
|
||||||
|
|
||||||
const { command, abortAll } = remote.require('./canvasPlayer');
|
|
||||||
|
|
||||||
export default ({ path, width: inWidth, height: inHeight, streamIndex, getCanvas }) => {
|
|
||||||
let terminated;
|
|
||||||
|
|
||||||
async function drawRawFrame(rgbaImage, width, height) {
|
|
||||||
const canvas = getCanvas();
|
|
||||||
if (!canvas || rgbaImage.length === 0) return;
|
|
||||||
|
|
||||||
canvas.width = width;
|
|
||||||
canvas.height = height;
|
|
||||||
|
|
||||||
const ctx = canvas.getContext('2d');
|
|
||||||
// https://developer.mozilla.org/en-US/docs/Web/API/ImageData/ImageData
|
|
||||||
// https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/putImageData
|
|
||||||
ctx.putImageData(new ImageData(Uint8ClampedArray.from(rgbaImage), width, height), 0, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
function drawJpegFrame(jpegImage, width, height) {
|
|
||||||
const canvas = getCanvas();
|
|
||||||
if (!canvas) return;
|
|
||||||
|
|
||||||
canvas.width = width;
|
|
||||||
canvas.height = height;
|
|
||||||
|
|
||||||
const ctx = canvas.getContext('2d');
|
|
||||||
|
|
||||||
const img = new Image();
|
|
||||||
img.onload = () => ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
|
|
||||||
img.onerror = (error) => console.error('Canvas JPEG image error', error);
|
|
||||||
img.src = `data:image/jpeg;base64,${jpegImage.toString('base64')}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function pause(seekTo) {
|
|
||||||
if (terminated) return;
|
|
||||||
abortAll();
|
|
||||||
command({ path, inWidth, inHeight, streamIndex, seekTo, onJpegFrame: drawJpegFrame, onRawFrame: drawRawFrame, playing: false });
|
|
||||||
}
|
|
||||||
|
|
||||||
function play(playFrom) {
|
|
||||||
if (terminated) return;
|
|
||||||
abortAll();
|
|
||||||
command({ path, inWidth, inHeight, streamIndex, seekTo: playFrom, onJpegFrame: drawJpegFrame, onRawFrame: drawRawFrame, playing: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
function terminate() {
|
|
||||||
if (terminated) return;
|
|
||||||
terminated = true;
|
|
||||||
abortAll();
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
play,
|
|
||||||
pause,
|
|
||||||
terminate,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
@ -0,0 +1,357 @@
|
|||||||
|
import { useEffect, useRef, useState, useCallback, useMemo, memo, CSSProperties } from 'react';
|
||||||
|
import { Spinner } from 'evergreen-ui';
|
||||||
|
import { useDebounce } from 'use-debounce';
|
||||||
|
|
||||||
|
import isDev from './isDev';
|
||||||
|
|
||||||
|
const remote = window.require('@electron/remote');
|
||||||
|
const { createMediaSourceStream, readOneJpegFrame } = remote.require('./canvasPlayer');
|
||||||
|
|
||||||
|
|
||||||
|
async function startPlayback({ path, video, videoStreamIndex, audioStreamIndex, seekTo, signal, playSafe, onCanPlay, getTargetTime, size, fps }: {
|
||||||
|
path: string,
|
||||||
|
video: HTMLVideoElement,
|
||||||
|
videoStreamIndex?: number,
|
||||||
|
audioStreamIndex?: number,
|
||||||
|
seekTo: number,
|
||||||
|
signal: AbortSignal,
|
||||||
|
playSafe: () => void,
|
||||||
|
onCanPlay: () => void,
|
||||||
|
getTargetTime: () => number,
|
||||||
|
size?: number,
|
||||||
|
fps?: number,
|
||||||
|
}) {
|
||||||
|
let canPlay = false;
|
||||||
|
let bufferEndTime: number | undefined;
|
||||||
|
let bufferStartTime = 0;
|
||||||
|
let stream;
|
||||||
|
let done = false;
|
||||||
|
let interval: NodeJS.Timeout | undefined;
|
||||||
|
let objectUrl: string | undefined;
|
||||||
|
let processChunkTimeout: NodeJS.Timeout;
|
||||||
|
|
||||||
|
function cleanup() {
|
||||||
|
console.log('Cleanup');
|
||||||
|
done = true;
|
||||||
|
video.pause();
|
||||||
|
if (interval != null) clearInterval(interval);
|
||||||
|
if (processChunkTimeout != null) clearInterval(processChunkTimeout);
|
||||||
|
stream?.abort();
|
||||||
|
if (objectUrl != null) URL.revokeObjectURL(objectUrl);
|
||||||
|
video.removeAttribute('src');
|
||||||
|
}
|
||||||
|
|
||||||
|
signal.addEventListener('abort', cleanup);
|
||||||
|
|
||||||
|
// See chrome://media-internals
|
||||||
|
|
||||||
|
const mediaSource = new MediaSource();
|
||||||
|
|
||||||
|
let streamTimestamp;
|
||||||
|
let lastRemoveTimestamp = 0;
|
||||||
|
|
||||||
|
function setStandardPlaybackRate() {
|
||||||
|
// set it a bit faster, so that we don't easily fall behind (better too fast than too slow)
|
||||||
|
// eslint-disable-next-line no-param-reassign
|
||||||
|
video.playbackRate = 1.05;
|
||||||
|
}
|
||||||
|
|
||||||
|
setStandardPlaybackRate();
|
||||||
|
|
||||||
|
const codecs: string[] = [];
|
||||||
|
if (videoStreamIndex != null) codecs.push('avc1.42C01F');
|
||||||
|
if (audioStreamIndex != null) codecs.push('mp4a.40.2');
|
||||||
|
const codecTag = codecs.join(', ');
|
||||||
|
|
||||||
|
const mimeCodec = `video/mp4; codecs="${codecTag}"`;
|
||||||
|
|
||||||
|
// mp4info sample-file.mp4 | grep Codec
|
||||||
|
// https://developer.mozilla.org/en-US/docs/Web/API/Media_Source_Extensions_API/Transcoding_assets_for_MSE
|
||||||
|
// https://stackoverflow.com/questions/16363167/html5-video-tag-codecs-attribute
|
||||||
|
// https://cconcolato.github.io/media-mime-support/
|
||||||
|
// https://github.com/cconcolato/media-mime-support
|
||||||
|
// const mimeCodec = 'video/mp4; codecs="avc1.42C01E"'; // Video only
|
||||||
|
// const mimeCodec = 'video/mp4; codecs="avc1.42E01E, mp4a.40.2"'; // Video+audio
|
||||||
|
|
||||||
|
if (!MediaSource.isTypeSupported(mimeCodec)) {
|
||||||
|
throw new Error(`Unsupported MIME type or codec: ${mimeCodec}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// console.log(mediaSource.readyState); // closed
|
||||||
|
objectUrl = URL.createObjectURL(mediaSource);
|
||||||
|
// eslint-disable-next-line no-param-reassign
|
||||||
|
video.src = objectUrl;
|
||||||
|
|
||||||
|
await new Promise((resolve) => mediaSource.addEventListener('sourceopen', resolve, { once: true }));
|
||||||
|
// console.log(mediaSource.readyState); // open
|
||||||
|
|
||||||
|
const sourceBuffer = mediaSource.addSourceBuffer(mimeCodec);
|
||||||
|
|
||||||
|
const getBufferEndTime = () => {
|
||||||
|
if (mediaSource.readyState !== 'open') {
|
||||||
|
console.log('mediaSource.readyState was not open, but:', mediaSource.readyState);
|
||||||
|
// else we will get: Uncaught DOMException: Failed to execute 'end' on 'TimeRanges': The index provided (0) is greater than or equal to the maximum bound (0).
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sourceBuffer.buffered.length < 1) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
// https://developer.mozilla.org/en-US/docs/Web/API/TimeRanges/start
|
||||||
|
return sourceBuffer.buffered.end(0);
|
||||||
|
};
|
||||||
|
|
||||||
|
sourceBuffer.addEventListener('updateend', () => {
|
||||||
|
playSafe();
|
||||||
|
}, { once: true });
|
||||||
|
|
||||||
|
let firstChunkReceived = false;
|
||||||
|
|
||||||
|
const processChunk = async () => {
|
||||||
|
try {
|
||||||
|
const chunk = await stream.readChunk();
|
||||||
|
if (chunk == null) {
|
||||||
|
console.log('End of stream');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (done) return;
|
||||||
|
|
||||||
|
if (!firstChunkReceived) {
|
||||||
|
firstChunkReceived = true;
|
||||||
|
console.log('First chunk received');
|
||||||
|
}
|
||||||
|
|
||||||
|
sourceBuffer.appendBuffer(chunk);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('processChunk failed', err);
|
||||||
|
processChunkTimeout = setTimeout(processChunk, 1000);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
sourceBuffer.addEventListener('error', (err) => console.error('sourceBuffer error, check DevTools ▶ More Tools ▶ Media', err));
|
||||||
|
|
||||||
|
// video.addEventListener('loadeddata', () => console.log('loadeddata'));
|
||||||
|
// video.addEventListener('play', () => console.log('play'));
|
||||||
|
video.addEventListener('canplay', () => {
|
||||||
|
console.log('canplay');
|
||||||
|
if (!canPlay) {
|
||||||
|
canPlay = true;
|
||||||
|
onCanPlay();
|
||||||
|
}
|
||||||
|
}, { once: true });
|
||||||
|
|
||||||
|
sourceBuffer.addEventListener('updateend', ({ timeStamp }) => {
|
||||||
|
if (done) return;
|
||||||
|
|
||||||
|
streamTimestamp = timeStamp; // apparently this timestamp cannot be trusted much
|
||||||
|
|
||||||
|
const bufferThrottleSec = isDev ? 5 : 10; // how many seconds ahead of playback we want to buffer
|
||||||
|
const bufferMaxSec = bufferThrottleSec + (isDev ? 5 : 60); // how many seconds we want to buffer in total (ahead of playback and behind)
|
||||||
|
|
||||||
|
bufferEndTime = getBufferEndTime();
|
||||||
|
|
||||||
|
// console.log('updateend', { bufferEndTime })
|
||||||
|
if (bufferEndTime != null) {
|
||||||
|
const targetTime = getTargetTime();
|
||||||
|
|
||||||
|
const bufferedTime = bufferEndTime - lastRemoveTimestamp;
|
||||||
|
|
||||||
|
if (bufferedTime > bufferMaxSec && !sourceBuffer.updating) {
|
||||||
|
try {
|
||||||
|
lastRemoveTimestamp = bufferEndTime;
|
||||||
|
const removeTo = bufferEndTime - bufferMaxSec;
|
||||||
|
bufferStartTime = removeTo;
|
||||||
|
console.log('sourceBuffer remove', 0, removeTo);
|
||||||
|
sourceBuffer.remove(0, removeTo); // updateend will be emitted again when this is done
|
||||||
|
return;
|
||||||
|
} catch (err) {
|
||||||
|
console.error('sourceBuffer remove failed', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const bufferAheadSec = bufferEndTime - targetTime;
|
||||||
|
if (bufferAheadSec > bufferThrottleSec) {
|
||||||
|
console.debug(`buffer ahead by ${bufferAheadSec}, throttling stream read`);
|
||||||
|
processChunkTimeout = setTimeout(processChunk, 1000);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// make sure we always process the next chunk
|
||||||
|
processChunk();
|
||||||
|
});
|
||||||
|
|
||||||
|
stream = createMediaSourceStream({ path, videoStreamIndex, audioStreamIndex, seekTo, size, fps });
|
||||||
|
|
||||||
|
interval = setInterval(() => {
|
||||||
|
if (mediaSource.readyState !== 'open') {
|
||||||
|
console.warn('mediaSource.readyState was not open, but:', mediaSource.readyState);
|
||||||
|
// else we will get: Uncaught DOMException: Failed to execute 'end' on 'TimeRanges': The index provided (0) is greater than or equal to the maximum bound (0).
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetTime = getTargetTime();
|
||||||
|
const playbackDiff = targetTime != null ? targetTime - video.currentTime : undefined;
|
||||||
|
|
||||||
|
const streamTimestampDiff = streamTimestamp != null && bufferEndTime != null ? (streamTimestamp / 1000) - bufferEndTime : undefined; // not really needed, but log for curiosity
|
||||||
|
console.debug('bufferStartTime', bufferStartTime, 'bufferEndTime', bufferEndTime, 'targetTime', targetTime, 'playback:', video.currentTime, 'playbackDiff:', playbackDiff, 'streamTimestamp diff:', streamTimestampDiff);
|
||||||
|
|
||||||
|
if (!canPlay || targetTime == null) return;
|
||||||
|
|
||||||
|
if (sourceBuffer.buffered.length !== 1) {
|
||||||
|
// not sure why this would happen or how to handle this
|
||||||
|
console.warn('sourceBuffer.buffered.length was', sourceBuffer.buffered.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((video.paused || video.ended) && !done) {
|
||||||
|
console.warn('Resuming unexpectedly paused video');
|
||||||
|
playSafe();
|
||||||
|
}
|
||||||
|
|
||||||
|
// make sure the playback keeps up
|
||||||
|
// https://stackoverflow.com/questions/23301496/how-to-keep-a-live-mediasource-video-stream-in-sync
|
||||||
|
if (playbackDiff != null && playbackDiff > 1) {
|
||||||
|
console.warn(`playback severely behind by ${playbackDiff}s, seeking to desired time`);
|
||||||
|
// eslint-disable-next-line no-param-reassign
|
||||||
|
video.currentTime = targetTime;
|
||||||
|
setStandardPlaybackRate();
|
||||||
|
} else if (playbackDiff != null && playbackDiff > 0.3) {
|
||||||
|
console.warn(`playback behind by ${playbackDiff}s, speeding up playback`);
|
||||||
|
// eslint-disable-next-line no-param-reassign
|
||||||
|
video.playbackRate = 1.5;
|
||||||
|
} else {
|
||||||
|
setStandardPlaybackRate();
|
||||||
|
}
|
||||||
|
}, 200);
|
||||||
|
|
||||||
|
// OK, everything initialized and ready to stream!
|
||||||
|
processChunk();
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawJpegFrame(canvas: HTMLCanvasElement, jpegImage: Buffer) {
|
||||||
|
if (!canvas) return;
|
||||||
|
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
|
||||||
|
const img = new Image();
|
||||||
|
if (ctx == null) {
|
||||||
|
console.error('Canvas context is null');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
img.onload = () => ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
|
||||||
|
img.onerror = (error) => console.error('Canvas JPEG image error', error);
|
||||||
|
img.src = `data:image/jpeg;base64,${jpegImage.toString('base64')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createPauseImage({ path, seekTo, videoStreamIndex, canvas, signal }) {
|
||||||
|
const { promise, abort } = readOneJpegFrame({ path, seekTo, videoStreamIndex });
|
||||||
|
signal.addEventListener('abort', () => abort());
|
||||||
|
const jpegImage = await promise;
|
||||||
|
drawJpegFrame(canvas, jpegImage);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MediaSourcePlayer({ rotate, filePath, playerTime, videoStream, audioStream, commandedTime, playing, eventId, masterVideoRef, mediaSourceQuality }) {
|
||||||
|
const videoRef = useRef<HTMLVideoElement>(null);
|
||||||
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
const onVideoError = useCallback((error) => {
|
||||||
|
console.error('video error', error);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const state = useMemo(() => (playing
|
||||||
|
? { startTime: commandedTime, playing, eventId }
|
||||||
|
: { startTime: playerTime, playing, eventId }
|
||||||
|
), [commandedTime, eventId, playerTime, playing]);
|
||||||
|
|
||||||
|
const [debouncedState] = useDebounce(state, 200, {
|
||||||
|
equalityFn: (a, b) => a.startTime === b.startTime && a.playing === b.playing && a.eventId === b.eventId,
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// console.log('debouncedState', debouncedState);
|
||||||
|
}, [debouncedState]);
|
||||||
|
|
||||||
|
const playSafe = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
await videoRef.current?.play();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('play failed', err);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
if (debouncedState.startTime == null) {
|
||||||
|
return () => undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const onCanPlay = () => setLoading(false);
|
||||||
|
const getTargetTime = () => masterVideoRef.current.currentTime - debouncedState.startTime;
|
||||||
|
|
||||||
|
const abortController = new AbortController();
|
||||||
|
|
||||||
|
const video = videoRef.current;
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
// When playing, we use a secondary video element, but when paused we use a canvas
|
||||||
|
if (debouncedState.playing) {
|
||||||
|
if (video == null) throw new Error('No video ref');
|
||||||
|
|
||||||
|
let size: number | undefined;
|
||||||
|
if (videoStream != null) {
|
||||||
|
if (mediaSourceQuality === 0) size = 800;
|
||||||
|
else if (mediaSourceQuality === 1) size = 420;
|
||||||
|
}
|
||||||
|
|
||||||
|
let fps: number | undefined;
|
||||||
|
if (mediaSourceQuality === 0) fps = 30;
|
||||||
|
else if (mediaSourceQuality === 1) fps = 15;
|
||||||
|
|
||||||
|
await startPlayback({ path: filePath, video, videoStreamIndex: videoStream?.index, audioStreamIndex: audioStream?.index, seekTo: debouncedState.startTime, signal: abortController.signal, playSafe, onCanPlay, getTargetTime, size, fps });
|
||||||
|
} else { // paused
|
||||||
|
if (videoStream != null) {
|
||||||
|
await createPauseImage({ path: filePath, seekTo: debouncedState.startTime, videoStreamIndex: videoStream.index, canvas: canvasRef.current, signal: abortController.signal });
|
||||||
|
}
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Preview failed', err);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
return () => abortController.abort();
|
||||||
|
// Important that we also have eventId in the deps, so that we can restart the preview when the eventId changes
|
||||||
|
}, [debouncedState.startTime, debouncedState.eventId, filePath, masterVideoRef, playSafe, debouncedState.playing, videoStream, mediaSourceQuality, audioStream?.index]);
|
||||||
|
|
||||||
|
const onFocus = useCallback((e) => {
|
||||||
|
// prevent video element from stealing focus in fullscreen mode https://github.com/mifi/lossless-cut/issues/543#issuecomment-1868167775
|
||||||
|
e.target.blur();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const { videoStyle, canvasStyle } = useMemo(() => {
|
||||||
|
const sharedStyle: CSSProperties = { position: 'absolute', left: 0, right: 0, top: 0, bottom: 0, display: 'block', width: '100%', height: '100%', objectFit: 'contain', transform: rotate ? `rotate(${rotate}deg)` : undefined };
|
||||||
|
|
||||||
|
return {
|
||||||
|
videoStyle: { ...sharedStyle, visibility: loading || !debouncedState.playing ? 'hidden' : undefined },
|
||||||
|
canvasStyle: { ...sharedStyle, visibility: loading || debouncedState.playing ? 'hidden' : undefined },
|
||||||
|
} as { videoStyle: CSSProperties, canvasStyle: CSSProperties };
|
||||||
|
}, [loading, debouncedState.playing, rotate]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ width: '100%', height: '100%', left: 0, right: 0, top: 0, bottom: 0, position: 'absolute', overflow: 'hidden', background: 'black', pointerEvents: 'none' }}>
|
||||||
|
{/* eslint-disable-next-line jsx-a11y/media-has-caption */}
|
||||||
|
<video style={videoStyle} ref={videoRef} playsInline onError={onVideoError} tabIndex={-1} onFocusCapture={onFocus} />
|
||||||
|
{videoStream != null && <canvas width={videoStream.width} height={videoStream.height} ref={canvasRef} style={canvasStyle} tabIndex={-1} onFocusCapture={onFocus} />}
|
||||||
|
|
||||||
|
{loading && (
|
||||||
|
<div style={{ position: 'absolute', top: 0, bottom: 0, left: 0, right: 0, display: 'flex', justifyContent: 'center', alignItems: 'center' }}><Spinner /></div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default memo(MediaSourcePlayer);
|
||||||
Loading…
Reference in New Issue