5878 lines
162 KiB
JavaScript
5878 lines
162 KiB
JavaScript
// src/writers/web-fs.ts
|
|
var createContent = async ({
|
|
filename
|
|
}) => {
|
|
const directoryHandle = await navigator.storage.getDirectory();
|
|
const actualFilename = `__remotion_mediaparser:${filename}`;
|
|
const remove = async () => {
|
|
try {
|
|
await directoryHandle.removeEntry(actualFilename, {
|
|
recursive: true
|
|
});
|
|
} catch {}
|
|
};
|
|
await remove();
|
|
const fileHandle = await directoryHandle.getFileHandle(actualFilename, {
|
|
create: true
|
|
});
|
|
const writable = await fileHandle.createWritable();
|
|
let written = 0;
|
|
let writPromise = Promise.resolve();
|
|
const write = async (arr) => {
|
|
await writable.write(arr);
|
|
written += arr.byteLength;
|
|
};
|
|
const updateDataAt = async (position, data) => {
|
|
await writable.seek(position);
|
|
await writable.write(data);
|
|
await writable.seek(written);
|
|
};
|
|
const writer = {
|
|
write: (arr) => {
|
|
writPromise = writPromise.then(() => write(arr));
|
|
return writPromise;
|
|
},
|
|
finish: async () => {
|
|
await writPromise;
|
|
try {
|
|
await writable.close();
|
|
} catch {}
|
|
},
|
|
async getBlob() {
|
|
const newHandle = await directoryHandle.getFileHandle(actualFilename, {
|
|
create: true
|
|
});
|
|
const newFile = await newHandle.getFile();
|
|
return newFile;
|
|
},
|
|
getWrittenByteCount: () => written,
|
|
updateDataAt: (position, data) => {
|
|
writPromise = writPromise.then(() => updateDataAt(position, data));
|
|
return writPromise;
|
|
},
|
|
remove
|
|
};
|
|
return writer;
|
|
};
|
|
var webFsWriter = {
|
|
createContent
|
|
};
|
|
var canUseWebFsWriter = async () => {
|
|
if (!("storage" in navigator)) {
|
|
return false;
|
|
}
|
|
if (!("getDirectory" in navigator.storage)) {
|
|
return false;
|
|
}
|
|
try {
|
|
const directoryHandle = await navigator.storage.getDirectory();
|
|
const fileHandle = await directoryHandle.getFileHandle("remotion-probe-web-fs-support", {
|
|
create: true
|
|
});
|
|
const canUse = fileHandle.createWritable !== undefined;
|
|
return canUse;
|
|
} catch {
|
|
return false;
|
|
}
|
|
};
|
|
|
|
// src/writers/buffer-implementation/writer.ts
|
|
var createContent2 = ({
|
|
filename,
|
|
mimeType
|
|
}) => {
|
|
const buf = new ArrayBuffer(0, {
|
|
maxByteLength: 2000000000
|
|
});
|
|
if (!buf.resize) {
|
|
throw new Error("Could not create buffer writer");
|
|
}
|
|
const write = (newData) => {
|
|
const oldLength = buf.byteLength;
|
|
const newLength = oldLength + newData.byteLength;
|
|
buf.resize(newLength);
|
|
const newArray = new Uint8Array(buf);
|
|
newArray.set(newData, oldLength);
|
|
};
|
|
const updateDataAt = (position, newData) => {
|
|
const newArray = new Uint8Array(buf);
|
|
newArray.set(newData, position);
|
|
};
|
|
let writPromise = Promise.resolve();
|
|
let removed = false;
|
|
const writer = {
|
|
write: (arr) => {
|
|
writPromise = writPromise.then(() => write(arr));
|
|
return writPromise;
|
|
},
|
|
finish: async () => {
|
|
await writPromise;
|
|
if (removed) {
|
|
return Promise.reject(new Error("Already called .remove() on the result"));
|
|
}
|
|
return Promise.resolve();
|
|
},
|
|
getBlob() {
|
|
const arr = new Uint8Array(buf);
|
|
return Promise.resolve(new File([arr.slice()], filename, { type: mimeType }));
|
|
},
|
|
remove() {
|
|
removed = true;
|
|
return Promise.resolve();
|
|
},
|
|
getWrittenByteCount: () => buf.byteLength,
|
|
updateDataAt: (position, newData) => {
|
|
writPromise = writPromise.then(() => updateDataAt(position, newData));
|
|
return writPromise;
|
|
}
|
|
};
|
|
return Promise.resolve(writer);
|
|
};
|
|
|
|
// src/writers/buffer.ts
|
|
var bufferWriter = {
|
|
createContent: createContent2
|
|
};
|
|
|
|
// src/resizing/calculate-new-size.ts
|
|
var ensureMultipleOfTwo = ({
|
|
dimensions,
|
|
needsToBeMultipleOfTwo
|
|
}) => {
|
|
if (!needsToBeMultipleOfTwo) {
|
|
return dimensions;
|
|
}
|
|
return {
|
|
width: Math.floor(dimensions.width / 2) * 2,
|
|
height: Math.floor(dimensions.height / 2) * 2
|
|
};
|
|
};
|
|
var calculateNewSizeAfterResizing = ({
|
|
dimensions,
|
|
resizeOperation,
|
|
needsToBeMultipleOfTwo
|
|
}) => {
|
|
if (resizeOperation === null) {
|
|
return ensureMultipleOfTwo({
|
|
dimensions,
|
|
needsToBeMultipleOfTwo
|
|
});
|
|
}
|
|
if (resizeOperation.mode === "width") {
|
|
return ensureMultipleOfTwo({
|
|
dimensions: {
|
|
width: resizeOperation.width,
|
|
height: Math.round(resizeOperation.width / dimensions.width * dimensions.height)
|
|
},
|
|
needsToBeMultipleOfTwo
|
|
});
|
|
}
|
|
if (resizeOperation.mode === "height") {
|
|
return ensureMultipleOfTwo({
|
|
dimensions: {
|
|
width: Math.round(resizeOperation.height / dimensions.height * dimensions.width),
|
|
height: resizeOperation.height
|
|
},
|
|
needsToBeMultipleOfTwo
|
|
});
|
|
}
|
|
if (resizeOperation.mode === "max-height") {
|
|
const height = Math.min(dimensions.height, resizeOperation.maxHeight);
|
|
return ensureMultipleOfTwo({
|
|
dimensions: {
|
|
width: Math.round(height / dimensions.height * dimensions.width),
|
|
height
|
|
},
|
|
needsToBeMultipleOfTwo
|
|
});
|
|
}
|
|
if (resizeOperation.mode === "max-width") {
|
|
const width = Math.min(dimensions.width, resizeOperation.maxWidth);
|
|
return ensureMultipleOfTwo({
|
|
dimensions: {
|
|
width,
|
|
height: Math.round(width / dimensions.width * dimensions.height)
|
|
},
|
|
needsToBeMultipleOfTwo
|
|
});
|
|
}
|
|
if (resizeOperation.mode === "max-height-width") {
|
|
const height = Math.min(dimensions.height, resizeOperation.maxHeight);
|
|
const width = Math.min(dimensions.width, resizeOperation.maxWidth);
|
|
const scale = Math.min(width / dimensions.width, height / dimensions.height);
|
|
const actualWidth = Math.round(dimensions.width * scale);
|
|
const actualHeight = Math.round(dimensions.height * scale);
|
|
return ensureMultipleOfTwo({
|
|
dimensions: {
|
|
height: actualHeight,
|
|
width: actualWidth
|
|
},
|
|
needsToBeMultipleOfTwo
|
|
});
|
|
}
|
|
if (resizeOperation.mode === "scale") {
|
|
if (resizeOperation.scale <= 0) {
|
|
throw new Error("Scale must be greater than 0");
|
|
}
|
|
const width = Math.round(dimensions.width * resizeOperation.scale);
|
|
const height = Math.round(dimensions.height * resizeOperation.scale);
|
|
return ensureMultipleOfTwo({
|
|
dimensions: {
|
|
width,
|
|
height
|
|
},
|
|
needsToBeMultipleOfTwo
|
|
});
|
|
}
|
|
throw new Error("Invalid resizing mode " + resizeOperation);
|
|
};
|
|
|
|
// src/rotation.ts
|
|
var calculateNewDimensionsFromRotate = ({
|
|
height,
|
|
width,
|
|
rotation
|
|
}) => {
|
|
const normalized = normalizeVideoRotation(rotation);
|
|
const switchDimensions = normalized % 90 === 0 && normalized % 180 !== 0;
|
|
const newHeight = switchDimensions ? width : height;
|
|
const newWidth = switchDimensions ? height : width;
|
|
return {
|
|
height: newHeight,
|
|
width: newWidth
|
|
};
|
|
};
|
|
var calculateNewDimensionsFromRotateAndScale = ({
|
|
width,
|
|
height,
|
|
rotation,
|
|
resizeOperation,
|
|
needsToBeMultipleOfTwo
|
|
}) => {
|
|
const { height: newHeight, width: newWidth } = calculateNewDimensionsFromRotate({
|
|
height,
|
|
rotation,
|
|
width
|
|
});
|
|
return calculateNewSizeAfterResizing({
|
|
dimensions: { height: newHeight, width: newWidth },
|
|
resizeOperation,
|
|
needsToBeMultipleOfTwo
|
|
});
|
|
};
|
|
|
|
// src/rotate-and-resize-video-frame.ts
|
|
var normalizeVideoRotation = (rotation) => {
|
|
return (rotation % 360 + 360) % 360;
|
|
};
|
|
var rotateAndResizeVideoFrame = ({
|
|
frame,
|
|
rotation,
|
|
needsToBeMultipleOfTwo = false,
|
|
resizeOperation
|
|
}) => {
|
|
const normalized = normalizeVideoRotation(rotation);
|
|
const mustProcess = "rotation" in frame && frame.rotation !== 0;
|
|
if (normalized === 0 && resizeOperation === null && !mustProcess) {
|
|
return frame;
|
|
}
|
|
if (normalized % 90 !== 0) {
|
|
throw new Error("Only 90 degree rotations are supported");
|
|
}
|
|
const tentativeDimensions = calculateNewDimensionsFromRotateAndScale({
|
|
height: frame.displayHeight,
|
|
width: frame.displayWidth,
|
|
rotation,
|
|
needsToBeMultipleOfTwo,
|
|
resizeOperation
|
|
});
|
|
if (normalized === 0 && tentativeDimensions.height === frame.displayHeight && tentativeDimensions.width === frame.displayWidth && !mustProcess) {
|
|
return frame;
|
|
}
|
|
const canvasRotationToApply = normalizeVideoRotation(normalized);
|
|
const { width, height } = calculateNewDimensionsFromRotateAndScale({
|
|
height: frame.displayHeight,
|
|
width: frame.displayWidth,
|
|
rotation: canvasRotationToApply,
|
|
needsToBeMultipleOfTwo,
|
|
resizeOperation
|
|
});
|
|
const canvas = new OffscreenCanvas(width, height);
|
|
const ctx = canvas.getContext("2d");
|
|
if (!ctx) {
|
|
throw new Error("Could not get 2d context");
|
|
}
|
|
canvas.width = width;
|
|
canvas.height = height;
|
|
if (canvasRotationToApply === 90) {
|
|
ctx.translate(width, 0);
|
|
} else if (canvasRotationToApply === 180) {
|
|
ctx.translate(width, height);
|
|
} else if (canvasRotationToApply === 270) {
|
|
ctx.translate(0, height);
|
|
}
|
|
if (canvasRotationToApply !== 0) {
|
|
ctx.rotate(canvasRotationToApply * (Math.PI / 180));
|
|
}
|
|
if (frame.displayHeight !== height || frame.displayWidth !== width) {
|
|
const dimensionsAfterRotate = calculateNewDimensionsFromRotate({
|
|
height: frame.displayHeight,
|
|
rotation: canvasRotationToApply,
|
|
width: frame.displayWidth
|
|
});
|
|
ctx.scale(width / dimensionsAfterRotate.width, height / dimensionsAfterRotate.height);
|
|
}
|
|
ctx.drawImage(frame, 0, 0);
|
|
return new VideoFrame(canvas, {
|
|
displayHeight: height,
|
|
displayWidth: width,
|
|
duration: frame.duration ?? undefined,
|
|
timestamp: frame.timestamp
|
|
});
|
|
};
|
|
|
|
// src/audio-encoder.ts
|
|
import {
|
|
MediaParserAbortError
|
|
} from "@remotion/media-parser";
|
|
|
|
// src/create/event-emitter.ts
|
|
class IoEventEmitter {
|
|
listeners = {
|
|
input: [],
|
|
output: [],
|
|
processed: [],
|
|
progress: []
|
|
};
|
|
addEventListener(name, callback) {
|
|
this.listeners[name].push(callback);
|
|
}
|
|
removeEventListener(name, callback) {
|
|
this.listeners[name] = this.listeners[name].filter((l) => l !== callback);
|
|
}
|
|
dispatchEvent(dispatchName, context) {
|
|
this.listeners[dispatchName].forEach((callback) => {
|
|
callback({ detail: context });
|
|
});
|
|
}
|
|
}
|
|
|
|
// src/create/with-resolvers.ts
|
|
var withResolvers = function() {
|
|
let resolve;
|
|
let reject;
|
|
const promise = new Promise((res, rej) => {
|
|
resolve = res;
|
|
reject = rej;
|
|
});
|
|
return { promise, resolve, reject };
|
|
};
|
|
var withResolversAndWaitForReturn = () => {
|
|
const { promise, reject, resolve } = withResolvers();
|
|
const { promise: returnPromise, resolve: resolveReturn } = withResolvers();
|
|
return {
|
|
getPromiseToImmediatelyReturn: () => {
|
|
resolveReturn(undefined);
|
|
return promise;
|
|
},
|
|
reject: (reason) => {
|
|
returnPromise.then(() => reject(reason));
|
|
},
|
|
resolve
|
|
};
|
|
};
|
|
|
|
// src/log.ts
|
|
import { MediaParserInternals } from "@remotion/media-parser";
|
|
var { Log } = MediaParserInternals;
|
|
|
|
// src/io-manager/make-timeout-promise.ts
|
|
var makeTimeoutPromise = ({
|
|
label,
|
|
ms,
|
|
controller
|
|
}) => {
|
|
const { promise, reject, resolve } = withResolvers();
|
|
let timeout = null;
|
|
const set = () => {
|
|
timeout = setTimeout(() => {
|
|
reject(new Error(`${label()} (timed out after ${ms}ms)`));
|
|
}, ms);
|
|
};
|
|
set();
|
|
const onPause = () => {
|
|
if (timeout) {
|
|
clearTimeout(timeout);
|
|
}
|
|
};
|
|
const onResume = () => {
|
|
set();
|
|
};
|
|
if (controller) {
|
|
controller.addEventListener("pause", onPause);
|
|
controller.addEventListener("resume", onResume);
|
|
}
|
|
return {
|
|
timeoutPromise: promise,
|
|
clear: () => {
|
|
if (timeout) {
|
|
clearTimeout(timeout);
|
|
}
|
|
resolve();
|
|
if (controller) {
|
|
controller.removeEventListener("pause", onPause);
|
|
controller.removeEventListener("resume", onResume);
|
|
}
|
|
}
|
|
};
|
|
};
|
|
|
|
// src/io-manager/io-synchronizer.ts
|
|
var makeIoSynchronizer = ({
|
|
logLevel,
|
|
label,
|
|
controller
|
|
}) => {
|
|
const eventEmitter = new IoEventEmitter;
|
|
let lastInput = 0;
|
|
let lastOutput = 0;
|
|
let inputsSinceLastOutput = 0;
|
|
let inputs = [];
|
|
let resolvers = [];
|
|
const getQueuedItems = () => {
|
|
inputs = inputs.filter((input) => Math.floor(input) > Math.floor(lastOutput) + 1);
|
|
return inputs.length;
|
|
};
|
|
const printState = (prefix) => {
|
|
Log.trace(logLevel, `[${label}] ${prefix}, state: Last input = ${lastInput} Last output = ${lastOutput} Inputs since last output = ${inputsSinceLastOutput}, Queue = ${getQueuedItems()}`);
|
|
};
|
|
const inputItem = (timestamp) => {
|
|
lastInput = timestamp;
|
|
inputsSinceLastOutput++;
|
|
inputs.push(timestamp);
|
|
eventEmitter.dispatchEvent("input", {
|
|
timestamp
|
|
});
|
|
printState("Input item");
|
|
};
|
|
const onOutput = (timestamp) => {
|
|
lastOutput = timestamp;
|
|
inputsSinceLastOutput = 0;
|
|
eventEmitter.dispatchEvent("output", {
|
|
timestamp
|
|
});
|
|
printState("Got output");
|
|
};
|
|
const waitForOutput = () => {
|
|
const { promise, resolve } = withResolvers();
|
|
const on = () => {
|
|
eventEmitter.removeEventListener("output", on);
|
|
resolve();
|
|
resolvers = resolvers.filter((resolver) => resolver !== resolve);
|
|
};
|
|
eventEmitter.addEventListener("output", on);
|
|
resolvers.push(resolve);
|
|
return promise;
|
|
};
|
|
const makeErrorBanner = () => {
|
|
return [
|
|
`Waited too long for ${label} to finish:`,
|
|
`${getQueuedItems()} queued items`,
|
|
`inputs: ${JSON.stringify(inputs)}`,
|
|
`last output: ${lastOutput}`
|
|
];
|
|
};
|
|
const waitForQueueSize = async (queueSize) => {
|
|
if (getQueuedItems() <= queueSize) {
|
|
return Promise.resolve();
|
|
}
|
|
const { timeoutPromise, clear } = makeTimeoutPromise({
|
|
label: () => [
|
|
...makeErrorBanner(),
|
|
`wanted: <${queueSize} queued items`,
|
|
`Report this at https://remotion.dev/report`
|
|
].join(`
|
|
`),
|
|
ms: 1e4,
|
|
controller
|
|
});
|
|
if (controller) {
|
|
controller._internals._mediaParserController._internals.signal.addEventListener("abort", clear);
|
|
}
|
|
await Promise.race([
|
|
timeoutPromise,
|
|
(async () => {
|
|
while (getQueuedItems() > queueSize) {
|
|
await waitForOutput();
|
|
}
|
|
})()
|
|
]).finally(() => clear());
|
|
if (controller) {
|
|
controller._internals._mediaParserController._internals.signal.removeEventListener("abort", clear);
|
|
}
|
|
};
|
|
const clearQueue = () => {
|
|
inputs.length = 0;
|
|
lastInput = 0;
|
|
lastOutput = 0;
|
|
inputsSinceLastOutput = 0;
|
|
resolvers.forEach((resolver) => {
|
|
return resolver();
|
|
});
|
|
resolvers.length = 0;
|
|
inputs.length = 0;
|
|
};
|
|
return {
|
|
inputItem,
|
|
onOutput,
|
|
waitForQueueSize,
|
|
clearQueue
|
|
};
|
|
};
|
|
|
|
// src/audio-data/data-types.ts
|
|
var getDataTypeForAudioFormat = (format) => {
|
|
switch (format) {
|
|
case "f32":
|
|
return Float32Array;
|
|
case "f32-planar":
|
|
return Float32Array;
|
|
case "s16":
|
|
return Int16Array;
|
|
case "s16-planar":
|
|
return Int16Array;
|
|
case "u8":
|
|
return Uint8Array;
|
|
case "u8-planar":
|
|
return Uint8Array;
|
|
case "s32":
|
|
return Int32Array;
|
|
case "s32-planar":
|
|
return Int32Array;
|
|
default:
|
|
throw new Error(`Unsupported audio format: ${format}`);
|
|
}
|
|
};
|
|
|
|
// src/audio-data/is-planar-format.ts
|
|
var isPlanarFormat = (format) => {
|
|
return format.includes("-planar");
|
|
};
|
|
|
|
// src/convert-audiodata.ts
|
|
var validateRange = (format, value) => {
|
|
if (format === "f32" || format === "f32-planar") {
|
|
if (value < -1 || value > 1) {
|
|
throw new Error("All values in a Float32 array must be between -1 and 1");
|
|
}
|
|
}
|
|
};
|
|
var convertAudioData = ({
|
|
audioData,
|
|
newSampleRate = audioData.sampleRate,
|
|
format = audioData.format
|
|
}) => {
|
|
const {
|
|
numberOfChannels,
|
|
sampleRate: currentSampleRate,
|
|
numberOfFrames: currentNumberOfFrames
|
|
} = audioData;
|
|
const ratio = currentSampleRate / newSampleRate;
|
|
const newNumberOfFrames = Math.floor(currentNumberOfFrames / ratio);
|
|
if (newNumberOfFrames === 0) {
|
|
throw new Error("Cannot resample - the given sample rate would result in less than 1 sample");
|
|
}
|
|
if (newSampleRate < 3000 || newSampleRate > 768000) {
|
|
throw new Error("newSampleRate must be between 3000 and 768000");
|
|
}
|
|
if (!format) {
|
|
throw new Error("AudioData format is not set");
|
|
}
|
|
if (format === audioData.format && newNumberOfFrames === currentNumberOfFrames) {
|
|
return audioData.clone();
|
|
}
|
|
const DataType = getDataTypeForAudioFormat(format);
|
|
const isPlanar = isPlanarFormat(format);
|
|
const planes = isPlanar ? numberOfChannels : 1;
|
|
const srcChannels = new Array(planes).fill(true).map(() => new DataType((isPlanar ? 1 : numberOfChannels) * currentNumberOfFrames));
|
|
for (let i = 0;i < planes; i++) {
|
|
audioData.clone().copyTo(srcChannels[i], {
|
|
planeIndex: i,
|
|
format
|
|
});
|
|
}
|
|
const data = new DataType(newNumberOfFrames * numberOfChannels);
|
|
const chunkSize = currentNumberOfFrames / newNumberOfFrames;
|
|
for (let newFrameIndex = 0;newFrameIndex < newNumberOfFrames; newFrameIndex++) {
|
|
const start = Math.floor(newFrameIndex * chunkSize);
|
|
const end = Math.max(Math.floor(start + chunkSize), start + 1);
|
|
if (isPlanar) {
|
|
for (let channelIndex = 0;channelIndex < numberOfChannels; channelIndex++) {
|
|
const chunk = srcChannels[channelIndex].slice(start, end);
|
|
const average = chunk.reduce((a, b) => {
|
|
return a + b;
|
|
}, 0) / chunk.length;
|
|
validateRange(format, average);
|
|
data[newFrameIndex + channelIndex * newNumberOfFrames] = average;
|
|
}
|
|
} else {
|
|
const sampleCountAvg = end - start;
|
|
for (let channelIndex = 0;channelIndex < numberOfChannels; channelIndex++) {
|
|
const items = [];
|
|
for (let k = 0;k < sampleCountAvg; k++) {
|
|
const num = srcChannels[0][(start + k) * numberOfChannels + channelIndex];
|
|
items.push(num);
|
|
}
|
|
const average = items.reduce((a, b) => a + b, 0) / items.length;
|
|
validateRange(format, average);
|
|
data[newFrameIndex * numberOfChannels + channelIndex] = average;
|
|
}
|
|
}
|
|
}
|
|
const newAudioData = new AudioData({
|
|
data,
|
|
format,
|
|
numberOfChannels,
|
|
numberOfFrames: newNumberOfFrames,
|
|
sampleRate: newSampleRate,
|
|
timestamp: audioData.timestamp
|
|
});
|
|
return newAudioData;
|
|
};
|
|
|
|
// src/wav-audio-encoder.ts
|
|
var getWaveAudioEncoder = ({
|
|
onChunk,
|
|
controller,
|
|
config,
|
|
ioSynchronizer
|
|
}) => {
|
|
return {
|
|
close: () => {
|
|
return Promise.resolve();
|
|
},
|
|
encode: (unconvertedAudioData) => {
|
|
if (controller._internals._mediaParserController._internals.signal.aborted) {
|
|
return Promise.resolve();
|
|
}
|
|
const audioData = convertAudioData({
|
|
audioData: unconvertedAudioData,
|
|
newSampleRate: config.sampleRate,
|
|
format: "s16"
|
|
});
|
|
unconvertedAudioData.close();
|
|
const chunk = {
|
|
timestamp: audioData.timestamp,
|
|
duration: audioData.duration,
|
|
type: "key",
|
|
copyTo: (destination) => audioData.copyTo(destination, { planeIndex: 0 }),
|
|
byteLength: audioData.allocationSize({ planeIndex: 0 })
|
|
};
|
|
return onChunk(chunk);
|
|
},
|
|
flush: () => Promise.resolve(),
|
|
waitForFinish: () => Promise.resolve(),
|
|
ioSynchronizer
|
|
};
|
|
};
|
|
|
|
// src/audio-encoder.ts
|
|
var createAudioEncoder = ({
|
|
onChunk,
|
|
onError,
|
|
codec,
|
|
controller,
|
|
config: audioEncoderConfig,
|
|
logLevel,
|
|
onNewAudioSampleRate
|
|
}) => {
|
|
if (controller._internals._mediaParserController._internals.signal.aborted) {
|
|
throw new MediaParserAbortError("Not creating audio encoder, already aborted");
|
|
}
|
|
const ioSynchronizer = makeIoSynchronizer({
|
|
logLevel,
|
|
label: "Audio encoder",
|
|
controller
|
|
});
|
|
if (codec === "wav") {
|
|
return getWaveAudioEncoder({
|
|
onChunk,
|
|
controller,
|
|
config: audioEncoderConfig,
|
|
ioSynchronizer
|
|
});
|
|
}
|
|
const encoder = new AudioEncoder({
|
|
output: async (chunk) => {
|
|
try {
|
|
await onChunk(chunk);
|
|
} catch (err) {
|
|
onError(err);
|
|
}
|
|
ioSynchronizer.onOutput(chunk.timestamp);
|
|
},
|
|
error(error) {
|
|
onError(error);
|
|
}
|
|
});
|
|
const close = () => {
|
|
controller._internals._mediaParserController._internals.signal.removeEventListener("abort", onAbort);
|
|
if (encoder.state === "closed") {
|
|
return;
|
|
}
|
|
encoder.close();
|
|
};
|
|
const onAbort = () => {
|
|
close();
|
|
};
|
|
controller._internals._mediaParserController._internals.signal.addEventListener("abort", onAbort);
|
|
if (codec !== "opus" && codec !== "aac") {
|
|
throw new Error('Only `codec: "opus"` and `codec: "aac"` is supported currently');
|
|
}
|
|
const wantedSampleRate = audioEncoderConfig.sampleRate;
|
|
const encodeFrame = (audioData) => {
|
|
if (encoder.state === "closed") {
|
|
return;
|
|
}
|
|
if (encoder.state === "unconfigured") {
|
|
if (audioData.sampleRate === wantedSampleRate) {
|
|
encoder.configure(audioEncoderConfig);
|
|
} else {
|
|
encoder.configure({
|
|
...audioEncoderConfig,
|
|
sampleRate: audioData.sampleRate
|
|
});
|
|
onNewAudioSampleRate(audioData.sampleRate);
|
|
}
|
|
}
|
|
encoder.encode(audioData);
|
|
ioSynchronizer.inputItem(audioData.timestamp);
|
|
};
|
|
return {
|
|
encode: (audioData) => {
|
|
encodeFrame(audioData);
|
|
},
|
|
waitForFinish: async () => {
|
|
await encoder.flush();
|
|
await ioSynchronizer.waitForQueueSize(0);
|
|
},
|
|
close,
|
|
flush: async () => {
|
|
await encoder.flush();
|
|
},
|
|
ioSynchronizer
|
|
};
|
|
};
|
|
// src/is-different-video-codec.ts
|
|
var isSameVideoCodec = ({
|
|
inputVideoCodec,
|
|
outputCodec
|
|
}) => {
|
|
if (outputCodec === "h264") {
|
|
return inputVideoCodec === "h264";
|
|
}
|
|
if (outputCodec === "h265") {
|
|
return inputVideoCodec === "h265";
|
|
}
|
|
if (outputCodec === "vp8") {
|
|
return inputVideoCodec === "vp8";
|
|
}
|
|
if (outputCodec === "vp9") {
|
|
return inputVideoCodec === "vp9";
|
|
}
|
|
throw new Error(`Unsupported output codec: ${outputCodec}`);
|
|
};
|
|
var isSameAudioCodec = ({
|
|
inputAudioCodec,
|
|
outputCodec
|
|
}) => {
|
|
if (outputCodec === "aac") {
|
|
return inputAudioCodec === "aac";
|
|
}
|
|
if (outputCodec === "opus") {
|
|
return inputAudioCodec === "opus";
|
|
}
|
|
if (outputCodec === "wav") {
|
|
return inputAudioCodec === "pcm-f32" || inputAudioCodec === "pcm-s16" || inputAudioCodec === "pcm-s24" || inputAudioCodec === "pcm-s32" || inputAudioCodec === "pcm-u8";
|
|
}
|
|
throw new Error(`Unsupported output codec: ${outputCodec}`);
|
|
};
|
|
|
|
// src/can-copy-audio-track.ts
|
|
var canCopyAudioTrack = ({
|
|
inputCodec,
|
|
outputContainer,
|
|
inputContainer,
|
|
outputAudioCodec
|
|
}) => {
|
|
if (outputAudioCodec) {
|
|
if (!isSameAudioCodec({
|
|
inputAudioCodec: inputCodec,
|
|
outputCodec: outputAudioCodec
|
|
})) {
|
|
return false;
|
|
}
|
|
}
|
|
if (outputContainer === "webm") {
|
|
return inputCodec === "opus";
|
|
}
|
|
if (outputContainer === "mp4") {
|
|
return inputCodec === "aac" && (inputContainer === "mp4" || inputContainer === "avi" || inputContainer === "m3u8");
|
|
}
|
|
if (outputContainer === "wav") {
|
|
return false;
|
|
}
|
|
throw new Error(`Unhandled container: ${outputContainer}`);
|
|
};
|
|
// src/can-copy-video-track.ts
|
|
var canCopyVideoTrack = ({
|
|
outputContainer,
|
|
rotationToApply,
|
|
inputContainer,
|
|
resizeOperation,
|
|
inputTrack,
|
|
outputVideoCodec
|
|
}) => {
|
|
if (normalizeVideoRotation(inputTrack.rotation) !== normalizeVideoRotation(rotationToApply)) {
|
|
return false;
|
|
}
|
|
if (outputVideoCodec) {
|
|
if (!isSameVideoCodec({
|
|
inputVideoCodec: inputTrack.codecEnum,
|
|
outputCodec: outputVideoCodec
|
|
})) {
|
|
return false;
|
|
}
|
|
}
|
|
const needsToBeMultipleOfTwo = inputTrack.codecEnum === "h264";
|
|
const newDimensions = calculateNewDimensionsFromRotateAndScale({
|
|
height: inputTrack.height,
|
|
resizeOperation,
|
|
rotation: rotationToApply,
|
|
width: inputTrack.width,
|
|
needsToBeMultipleOfTwo
|
|
});
|
|
if (newDimensions.height !== inputTrack.height || newDimensions.width !== inputTrack.width) {
|
|
return false;
|
|
}
|
|
if (outputContainer === "webm") {
|
|
return inputTrack.codecEnum === "vp8" || inputTrack.codecEnum === "vp9";
|
|
}
|
|
if (outputContainer === "mp4") {
|
|
return (inputTrack.codecEnum === "h264" || inputTrack.codecEnum === "h265") && (inputContainer === "mp4" || inputContainer === "avi" || inputContainer === "m3u8" && inputTrack.m3uStreamFormat === "mp4");
|
|
}
|
|
if (outputContainer === "wav") {
|
|
return false;
|
|
}
|
|
throw new Error(`Unhandled codec: ${outputContainer}`);
|
|
};
|
|
// src/audio-decoder-config.ts
|
|
var getAudioDecoderConfig = async (config) => {
|
|
if (config.codec === "pcm-s16") {
|
|
return config;
|
|
}
|
|
if (config.codec === "pcm-s24") {
|
|
return config;
|
|
}
|
|
if (typeof AudioDecoder === "undefined") {
|
|
return null;
|
|
}
|
|
if (typeof EncodedAudioChunk === "undefined") {
|
|
return null;
|
|
}
|
|
if ((await AudioDecoder.isConfigSupported(config)).supported) {
|
|
return config;
|
|
}
|
|
return null;
|
|
};
|
|
|
|
// src/audio-encoder-config.ts
|
|
var getCodecString = (audioCodec) => {
|
|
if (audioCodec === "opus") {
|
|
return "opus";
|
|
}
|
|
if (audioCodec === "aac") {
|
|
return "mp4a.40.02";
|
|
}
|
|
if (audioCodec === "wav") {
|
|
return "wav-should-not-to-into-audio-encoder";
|
|
}
|
|
throw new Error(`Unsupported audio codec: ${audioCodec}`);
|
|
};
|
|
var getAudioEncoderConfig = async (config) => {
|
|
const actualConfig = {
|
|
...config,
|
|
codec: getCodecString(config.codec)
|
|
};
|
|
if (config.codec === "wav") {
|
|
return actualConfig;
|
|
}
|
|
if (typeof AudioEncoder === "undefined") {
|
|
return null;
|
|
}
|
|
if ((await AudioEncoder.isConfigSupported(actualConfig)).supported) {
|
|
return actualConfig;
|
|
}
|
|
const maybeItIsTheSampleRateThatIsTheProblem = config.sampleRate !== 48000 && config.sampleRate !== 44100;
|
|
if (maybeItIsTheSampleRateThatIsTheProblem) {
|
|
return getAudioEncoderConfig({
|
|
...config,
|
|
sampleRate: config.sampleRate === 22050 ? 44100 : 48000
|
|
});
|
|
}
|
|
return null;
|
|
};
|
|
|
|
// src/can-reencode-audio-track.ts
|
|
var canReencodeAudioTrack = async ({
|
|
track,
|
|
audioCodec,
|
|
bitrate,
|
|
sampleRate
|
|
}) => {
|
|
const audioDecoderConfig = await getAudioDecoderConfig(track);
|
|
if (audioCodec === "wav" && audioDecoderConfig) {
|
|
return true;
|
|
}
|
|
const audioEncoderConfig = await getAudioEncoderConfig({
|
|
codec: audioCodec,
|
|
numberOfChannels: track.numberOfChannels,
|
|
sampleRate: sampleRate ?? track.sampleRate,
|
|
bitrate
|
|
});
|
|
return Boolean(audioDecoderConfig && audioEncoderConfig);
|
|
};
|
|
// src/video-decoder-config.ts
|
|
var getVideoDecoderConfigWithHardwareAcceleration = async (config) => {
|
|
if (typeof VideoDecoder === "undefined") {
|
|
return null;
|
|
}
|
|
const hardware = {
|
|
...config,
|
|
hardwareAcceleration: "prefer-hardware"
|
|
};
|
|
if ((await VideoDecoder.isConfigSupported(hardware)).supported) {
|
|
return hardware;
|
|
}
|
|
const software = {
|
|
...config,
|
|
hardwareAcceleration: "prefer-software"
|
|
};
|
|
if ((await VideoDecoder.isConfigSupported(software)).supported) {
|
|
return software;
|
|
}
|
|
return null;
|
|
};
|
|
|
|
// src/browser-quirks.ts
|
|
var isFirefox = () => {
|
|
return navigator.userAgent.toLowerCase().indexOf("firefox") > -1;
|
|
};
|
|
var isSafari = () => {
|
|
return /^((?!chrome|android).)*safari/i.test(navigator.userAgent);
|
|
};
|
|
|
|
// src/choose-correct-avc1-profile.ts
|
|
var chooseCorrectAvc1Profile = ({
|
|
width,
|
|
height,
|
|
fps
|
|
}) => {
|
|
const profiles = [
|
|
{ level: "3.1", hex: "1F", width: 1280, height: 720, fps: 30 },
|
|
{ level: "3.2", hex: "20", width: 1280, height: 1024, fps: 42.2 },
|
|
{ level: "4.0", hex: "28", width: 2048, height: 1024, fps: 30 },
|
|
{ level: "4.1", hex: "29", width: 2048, height: 1024, fps: 30 },
|
|
{ level: "4.2", hex: "2A", width: 2048, height: 1080, fps: 60 },
|
|
{ level: "5.0", hex: "32", width: 3672, height: 1536, fps: 26.7 },
|
|
{ level: "5.1", hex: "33", width: 4096, height: 2304, fps: 26.7 },
|
|
{ level: "5.2", hex: "34", width: 4096, height: 2304, fps: 56.3 },
|
|
{ level: "6.0", hex: "3C", width: 8192, height: 4320, fps: 30.2 },
|
|
{ level: "6.1", hex: "3D", width: 8192, height: 4320, fps: 60.4 },
|
|
{ level: "6.2", hex: "3E", width: 8192, height: 4320, fps: 120.8 }
|
|
];
|
|
const profile = profiles.find((p) => {
|
|
if (width > p.width) {
|
|
return false;
|
|
}
|
|
if (height > p.height) {
|
|
return false;
|
|
}
|
|
const fallbackFps = fps ?? 60;
|
|
return fallbackFps <= p.fps;
|
|
});
|
|
if (!profile) {
|
|
throw new Error(`No suitable AVC1 profile found for ${width}x${height}@${fps}fps`);
|
|
}
|
|
return `avc1.6400${profile.hex}`;
|
|
};
|
|
|
|
// src/hevc-levels.ts
|
|
var hevcLevels = [
|
|
{
|
|
level: "3.1",
|
|
maxBitrateMainTier: 1e4,
|
|
maxBitrateHighTier: null,
|
|
maxResolutionsAndFrameRates: [
|
|
{
|
|
width: 720,
|
|
height: 480,
|
|
fps: 84.3
|
|
},
|
|
{
|
|
width: 720,
|
|
height: 576,
|
|
fps: 75
|
|
},
|
|
{
|
|
width: 960,
|
|
height: 540,
|
|
fps: 60
|
|
},
|
|
{
|
|
width: 1280,
|
|
height: 720,
|
|
fps: 33.7
|
|
}
|
|
]
|
|
},
|
|
{
|
|
level: "4",
|
|
maxBitrateMainTier: 12000,
|
|
maxBitrateHighTier: 30000,
|
|
maxResolutionsAndFrameRates: [
|
|
{
|
|
width: 1280,
|
|
height: 720,
|
|
fps: 68
|
|
},
|
|
{
|
|
width: 1920,
|
|
height: 1080,
|
|
fps: 32
|
|
},
|
|
{
|
|
width: 2048,
|
|
height: 1080,
|
|
fps: 30
|
|
}
|
|
]
|
|
},
|
|
{
|
|
level: "4.1",
|
|
maxBitrateMainTier: 20000,
|
|
maxBitrateHighTier: 50000,
|
|
maxResolutionsAndFrameRates: [
|
|
{
|
|
width: 1280,
|
|
height: 720,
|
|
fps: 136
|
|
},
|
|
{
|
|
width: 1920,
|
|
height: 1080,
|
|
fps: 64
|
|
},
|
|
{
|
|
width: 2048,
|
|
height: 1080,
|
|
fps: 60
|
|
}
|
|
]
|
|
},
|
|
{
|
|
level: "5",
|
|
maxBitrateMainTier: 25000,
|
|
maxBitrateHighTier: 1e5,
|
|
maxResolutionsAndFrameRates: [
|
|
{
|
|
width: 1920,
|
|
height: 1080,
|
|
fps: 128
|
|
},
|
|
{
|
|
width: 2048,
|
|
height: 1080,
|
|
fps: 120
|
|
},
|
|
{
|
|
width: 3840,
|
|
height: 2160,
|
|
fps: 32
|
|
},
|
|
{
|
|
width: 4096,
|
|
height: 2160,
|
|
fps: 30
|
|
}
|
|
]
|
|
},
|
|
{
|
|
level: "5.1",
|
|
maxBitrateMainTier: 40000,
|
|
maxBitrateHighTier: 160000,
|
|
maxResolutionsAndFrameRates: [
|
|
{
|
|
width: 1920,
|
|
height: 1080,
|
|
fps: 256
|
|
},
|
|
{
|
|
width: 2048,
|
|
height: 1080,
|
|
fps: 240
|
|
},
|
|
{
|
|
width: 3840,
|
|
height: 2160,
|
|
fps: 64
|
|
},
|
|
{
|
|
width: 4096,
|
|
height: 2160,
|
|
fps: 60
|
|
}
|
|
]
|
|
},
|
|
{
|
|
level: "5.2",
|
|
maxBitrateMainTier: 60000,
|
|
maxBitrateHighTier: 240000,
|
|
maxResolutionsAndFrameRates: [
|
|
{
|
|
width: 2048,
|
|
height: 1080,
|
|
fps: 300
|
|
},
|
|
{
|
|
width: 3840,
|
|
height: 2160,
|
|
fps: 128
|
|
},
|
|
{
|
|
width: 4096,
|
|
height: 2160,
|
|
fps: 120
|
|
}
|
|
]
|
|
},
|
|
{
|
|
level: "6",
|
|
maxBitrateMainTier: 60000,
|
|
maxBitrateHighTier: 240000,
|
|
maxResolutionsAndFrameRates: [
|
|
{
|
|
width: 3840,
|
|
height: 2160,
|
|
fps: 128
|
|
},
|
|
{
|
|
width: 4096,
|
|
height: 2160,
|
|
fps: 120
|
|
},
|
|
{
|
|
width: 7680,
|
|
height: 4320,
|
|
fps: 32
|
|
},
|
|
{
|
|
width: 8192,
|
|
height: 4320,
|
|
fps: 30
|
|
}
|
|
]
|
|
},
|
|
{
|
|
level: "6.1",
|
|
maxBitrateMainTier: 120000,
|
|
maxBitrateHighTier: 480000,
|
|
maxResolutionsAndFrameRates: [
|
|
{
|
|
width: 3840,
|
|
height: 2160,
|
|
fps: 256
|
|
},
|
|
{
|
|
width: 4096,
|
|
height: 2160,
|
|
fps: 240
|
|
},
|
|
{
|
|
width: 7680,
|
|
height: 4320,
|
|
fps: 64
|
|
},
|
|
{
|
|
width: 8192,
|
|
height: 4320,
|
|
fps: 60
|
|
}
|
|
]
|
|
},
|
|
{
|
|
level: "6.2",
|
|
maxBitrateMainTier: 240000,
|
|
maxBitrateHighTier: 800000,
|
|
maxResolutionsAndFrameRates: [
|
|
{
|
|
width: 3840,
|
|
height: 2160,
|
|
fps: 512
|
|
},
|
|
{
|
|
width: 4096,
|
|
height: 2160,
|
|
fps: 480
|
|
},
|
|
{
|
|
width: 7680,
|
|
height: 4320,
|
|
fps: 128
|
|
},
|
|
{
|
|
width: 8192,
|
|
height: 4320,
|
|
fps: 120
|
|
}
|
|
]
|
|
}
|
|
];
|
|
|
|
// src/choose-correct-hevc-profile.ts
|
|
var chooseCorrectHevcProfile = ({
|
|
width,
|
|
height,
|
|
fps
|
|
}) => {
|
|
const profile = hevcLevels.find((p) => {
|
|
return p.maxResolutionsAndFrameRates.some((max) => {
|
|
if (width > max.width) {
|
|
return false;
|
|
}
|
|
if (height > max.height) {
|
|
return false;
|
|
}
|
|
const fallbackFps = fps ?? 60;
|
|
return fallbackFps <= max.fps;
|
|
});
|
|
});
|
|
if (!profile) {
|
|
throw new Error(`No suitable HEVC profile found for ${width}x${height}@${fps}fps`);
|
|
}
|
|
return `hvc1.${1}.${0}.${"L"}${Math.round(Number(profile.level) * 30)}.${"b0"}`;
|
|
};
|
|
|
|
// src/get-codec-string.ts
|
|
var getCodecStringForEncoder = ({
|
|
codec,
|
|
fps,
|
|
height,
|
|
width
|
|
}) => {
|
|
if (codec === "h264") {
|
|
return chooseCorrectAvc1Profile({ fps, height, width });
|
|
}
|
|
if (codec === "h265") {
|
|
return chooseCorrectHevcProfile({ fps, height, width });
|
|
}
|
|
if (codec === "vp8") {
|
|
return "vp8";
|
|
}
|
|
if (codec === "vp9") {
|
|
return "vp09.00.10.08";
|
|
}
|
|
throw new Error(`Unknown codec: ${codec}`);
|
|
};
|
|
|
|
// src/video-encoder-config.ts
|
|
var getVideoEncoderConfig = async ({
|
|
codec,
|
|
height,
|
|
width,
|
|
fps
|
|
}) => {
|
|
if (typeof VideoEncoder === "undefined") {
|
|
return null;
|
|
}
|
|
const config = {
|
|
codec: getCodecStringForEncoder({ codec, fps, height, width }),
|
|
height,
|
|
width,
|
|
bitrate: isSafari() ? 3000000 : undefined,
|
|
bitrateMode: codec === "vp9" && !isSafari() ? "quantizer" : undefined,
|
|
framerate: fps ?? undefined
|
|
};
|
|
const hardware = {
|
|
...config,
|
|
hardwareAcceleration: "prefer-hardware"
|
|
};
|
|
if ((await VideoEncoder.isConfigSupported(hardware)).supported) {
|
|
return hardware;
|
|
}
|
|
const software = {
|
|
...config,
|
|
hardwareAcceleration: "prefer-software"
|
|
};
|
|
if ((await VideoEncoder.isConfigSupported(software)).supported) {
|
|
return software;
|
|
}
|
|
return null;
|
|
};
|
|
|
|
// src/can-reencode-video-track.ts
|
|
var canReencodeVideoTrack = async ({
|
|
videoCodec,
|
|
track,
|
|
resizeOperation,
|
|
rotate
|
|
}) => {
|
|
const { height, width } = calculateNewDimensionsFromRotateAndScale({
|
|
height: track.displayAspectHeight,
|
|
resizeOperation,
|
|
rotation: rotate ?? 0,
|
|
needsToBeMultipleOfTwo: videoCodec === "h264",
|
|
width: track.displayAspectWidth
|
|
});
|
|
const videoEncoderConfig = await getVideoEncoderConfig({
|
|
codec: videoCodec,
|
|
height,
|
|
width,
|
|
fps: track.fps
|
|
});
|
|
const videoDecoderConfig = await getVideoDecoderConfigWithHardwareAcceleration(track);
|
|
return Boolean(videoDecoderConfig && videoEncoderConfig);
|
|
};
|
|
// src/convert-media.ts
|
|
import {
|
|
defaultSelectM3uAssociatedPlaylists,
|
|
defaultSelectM3uStreamFn,
|
|
MediaParserAbortError as MediaParserAbortError3,
|
|
MediaParserInternals as MediaParserInternals9
|
|
} from "@remotion/media-parser";
|
|
import { webReader } from "@remotion/media-parser/web";
|
|
|
|
// src/auto-select-writer.ts
|
|
var autoSelectWriter = async (writer, logLevel) => {
|
|
if (writer) {
|
|
Log.verbose(logLevel, "Using writer provided by user");
|
|
return writer;
|
|
}
|
|
Log.verbose(logLevel, "Determining best writer");
|
|
const hasNavigator = typeof navigator !== "undefined";
|
|
if (!hasNavigator) {
|
|
Log.verbose(logLevel, "No navigator API detected, using buffer writer");
|
|
return bufferWriter;
|
|
}
|
|
const isOffline = !navigator.onLine;
|
|
if (isOffline) {
|
|
Log.verbose(logLevel, "Offline mode detected, using buffer writer");
|
|
return bufferWriter;
|
|
}
|
|
try {
|
|
const { promise: timeout, reject, resolve } = withResolvers();
|
|
const time = setTimeout(() => reject(new Error("WebFS check timeout")), 2000);
|
|
const webFsSupported = await Promise.race([canUseWebFsWriter(), timeout]);
|
|
resolve();
|
|
clearTimeout(time);
|
|
if (webFsSupported) {
|
|
Log.verbose(logLevel, "Using WebFS writer because it is supported");
|
|
return webFsWriter;
|
|
}
|
|
} catch (err) {
|
|
Log.verbose(logLevel, `WebFS check failed: ${err}. Falling back to buffer writer`);
|
|
}
|
|
Log.verbose(logLevel, "Using buffer writer because WebFS writer is not supported or unavailable");
|
|
return bufferWriter;
|
|
};
|
|
|
|
// src/calculate-progress.ts
|
|
var calculateProgress = ({
|
|
millisecondsWritten,
|
|
expectedOutputDurationInMs
|
|
}) => {
|
|
if (expectedOutputDurationInMs === null) {
|
|
return null;
|
|
}
|
|
return millisecondsWritten / expectedOutputDurationInMs;
|
|
};
|
|
|
|
// src/create/iso-base-media/create-iso-base-media.ts
|
|
import {
|
|
MediaParserInternals as MediaParserInternals3
|
|
} from "@remotion/media-parser";
|
|
|
|
// src/create/matroska/matroska-utils.ts
|
|
import { MediaParserInternals as MediaParserInternals2 } from "@remotion/media-parser";
|
|
var getIdForName = (name) => {
|
|
const value = Object.entries(MediaParserInternals2.matroskaElements).find(([key]) => key === name)?.[1];
|
|
if (!value) {
|
|
throw new Error(`Could not find id for name ${name}`);
|
|
}
|
|
return value;
|
|
};
|
|
function putUintDynamic(number, minimumLength) {
|
|
if (number < 0) {
|
|
throw new Error("This function is designed for non-negative integers only.");
|
|
}
|
|
const length = Math.max(minimumLength ?? 1, Math.ceil(Math.log2(number + 1) / 8));
|
|
const bytes = new Uint8Array(length);
|
|
for (let i = 0;i < length; i++) {
|
|
bytes[length - 1 - i] = number >> 8 * i & 255;
|
|
}
|
|
return bytes;
|
|
}
|
|
var makeFromStructure = (fields) => {
|
|
if ("bytes" in fields) {
|
|
return fields;
|
|
}
|
|
const arrays = [];
|
|
const struct = MediaParserInternals2.ebmlMap[getIdForName(fields.type)];
|
|
if (struct.type === "uint8array") {
|
|
return {
|
|
bytes: fields.value,
|
|
offsets: { offset: 0, children: [], field: fields.type }
|
|
};
|
|
}
|
|
if (struct.type === "children") {
|
|
const children = [];
|
|
let bytesWritten = 0;
|
|
for (const item of fields.value) {
|
|
const { bytes, offsets } = makeMatroskaBytes(item);
|
|
arrays.push(bytes);
|
|
children.push(incrementOffsetAndChildren(offsets, bytesWritten));
|
|
bytesWritten += bytes.byteLength;
|
|
}
|
|
return {
|
|
bytes: combineUint8Arrays(arrays),
|
|
offsets: { offset: 0, children, field: fields.type }
|
|
};
|
|
}
|
|
if (struct.type === "string") {
|
|
return {
|
|
bytes: new TextEncoder().encode(fields.value),
|
|
offsets: {
|
|
children: [],
|
|
offset: 0,
|
|
field: fields.type
|
|
}
|
|
};
|
|
}
|
|
if (struct.type === "uint") {
|
|
return {
|
|
bytes: putUintDynamic(fields.value.value, fields.value.byteLength),
|
|
offsets: {
|
|
children: [],
|
|
offset: 0,
|
|
field: fields.type
|
|
}
|
|
};
|
|
}
|
|
if (struct.type === "hex-string") {
|
|
const hex = fields.value.substring(2);
|
|
const arr = new Uint8Array(hex.length / 2);
|
|
for (let i = 0;i < hex.length; i += 2) {
|
|
const byte = parseInt(hex.substring(i, i + 2), 16);
|
|
arr[i / 2] = byte;
|
|
}
|
|
return {
|
|
bytes: arr,
|
|
offsets: {
|
|
children: [],
|
|
offset: 0,
|
|
field: fields.type
|
|
}
|
|
};
|
|
}
|
|
if (struct.type === "float") {
|
|
const value = fields.value;
|
|
if (value.size === "32") {
|
|
const dataView = new DataView(new ArrayBuffer(4));
|
|
dataView.setFloat32(0, value.value);
|
|
return {
|
|
bytes: new Uint8Array(dataView.buffer),
|
|
offsets: {
|
|
children: [],
|
|
offset: 0,
|
|
field: fields.type
|
|
}
|
|
};
|
|
}
|
|
const dataView2 = new DataView(new ArrayBuffer(8));
|
|
dataView2.setFloat64(0, value.value);
|
|
return {
|
|
bytes: new Uint8Array(dataView2.buffer),
|
|
offsets: {
|
|
children: [],
|
|
offset: 0,
|
|
field: fields.type
|
|
}
|
|
};
|
|
}
|
|
throw new Error("Unexpected type");
|
|
};
|
|
var combineUint8Arrays = (arrays) => {
|
|
if (arrays.length === 0) {
|
|
return new Uint8Array([]);
|
|
}
|
|
if (arrays.length === 1) {
|
|
return arrays[0];
|
|
}
|
|
let totalLength = 0;
|
|
for (const array of arrays) {
|
|
totalLength += array.length;
|
|
}
|
|
const result = new Uint8Array(totalLength);
|
|
let offset = 0;
|
|
for (const array of arrays) {
|
|
result.set(array, offset);
|
|
offset += array.length;
|
|
}
|
|
return result;
|
|
};
|
|
var incrementOffsetAndChildren = (offset, increment) => {
|
|
return {
|
|
offset: offset.offset + increment,
|
|
children: offset.children.map((c) => incrementOffsetAndChildren(c, increment)),
|
|
field: offset.field
|
|
};
|
|
};
|
|
var matroskaToHex = (matrId) => {
|
|
const numbers = new Uint8Array((matrId.length - 2) / 2);
|
|
for (let i = 2;i < matrId.length; i += 2) {
|
|
const hex = matrId.substring(i, i + 2);
|
|
numbers[(i - 2) / 2] = parseInt(hex, 16);
|
|
}
|
|
return numbers;
|
|
};
|
|
var measureEBMLVarInt = (value) => {
|
|
if (value < (1 << 7) - 1) {
|
|
return 1;
|
|
}
|
|
if (value < (1 << 14) - 1) {
|
|
return 2;
|
|
}
|
|
if (value < (1 << 21) - 1) {
|
|
return 3;
|
|
}
|
|
if (value < (1 << 28) - 1) {
|
|
return 4;
|
|
}
|
|
if (value < 2 ** 35 - 1) {
|
|
return 5;
|
|
}
|
|
if (value < 2 ** 42 - 1) {
|
|
return 6;
|
|
}
|
|
throw new Error("EBML VINT size not supported " + value);
|
|
};
|
|
var getVariableInt = (value, minWidth) => {
|
|
const width = Math.max(measureEBMLVarInt(value), minWidth ?? 0);
|
|
switch (width) {
|
|
case 1:
|
|
return new Uint8Array([1 << 7 | value]);
|
|
case 2:
|
|
return new Uint8Array([1 << 6 | value >> 8, value]);
|
|
case 3:
|
|
return new Uint8Array([1 << 5 | value >> 16, value >> 8, value]);
|
|
case 4:
|
|
return new Uint8Array([
|
|
1 << 4 | value >> 24,
|
|
value >> 16,
|
|
value >> 8,
|
|
value
|
|
]);
|
|
case 5:
|
|
return new Uint8Array([
|
|
1 << 3 | value / 2 ** 32 & 7,
|
|
value >> 24,
|
|
value >> 16,
|
|
value >> 8,
|
|
value
|
|
]);
|
|
case 6:
|
|
return new Uint8Array([
|
|
1 << 2 | value / 2 ** 40 & 3,
|
|
value / 2 ** 32 | 0,
|
|
value >> 24,
|
|
value >> 16,
|
|
value >> 8,
|
|
value
|
|
]);
|
|
case 7:
|
|
return new Uint8Array([
|
|
1 << 1 | value / 2 ** 48 & 1,
|
|
value / 2 ** 40 | 0,
|
|
value / 2 ** 32 | 0,
|
|
value >> 24,
|
|
value >> 16,
|
|
value >> 8,
|
|
value
|
|
]);
|
|
case 8:
|
|
return new Uint8Array([
|
|
1 << 0 | value / 2 ** 56 & 1,
|
|
value / 2 ** 48 | 0,
|
|
value / 2 ** 40 | 0,
|
|
value / 2 ** 32 | 0,
|
|
value >> 24,
|
|
value >> 16,
|
|
value >> 8,
|
|
value
|
|
]);
|
|
default:
|
|
throw new Error("Bad EBML VINT size " + width);
|
|
}
|
|
};
|
|
var makeMatroskaBytes = (fields) => {
|
|
if ("bytes" in fields) {
|
|
return fields;
|
|
}
|
|
const value = makeFromStructure(fields);
|
|
const header = matroskaToHex(getIdForName(fields.type));
|
|
const size = getVariableInt(value.bytes.length, fields.minVintWidth);
|
|
const bytes = combineUint8Arrays([header, size, value.bytes]);
|
|
return {
|
|
bytes,
|
|
offsets: {
|
|
offset: value.offsets.offset,
|
|
field: value.offsets.field,
|
|
children: value.offsets.children.map((c) => {
|
|
return incrementOffsetAndChildren(c, header.byteLength + size.byteLength);
|
|
})
|
|
}
|
|
};
|
|
};
|
|
var padMatroskaBytes = (fields, totalLength) => {
|
|
const regular = makeMatroskaBytes(fields);
|
|
const paddingLength = totalLength - regular.bytes.byteLength - matroskaToHex(MediaParserInternals2.matroskaElements.Void).byteLength;
|
|
if (paddingLength < 0) {
|
|
throw new Error("ooops");
|
|
}
|
|
const padding = makeMatroskaBytes({
|
|
type: "Void",
|
|
value: new Uint8Array(paddingLength).fill(0),
|
|
minVintWidth: null
|
|
});
|
|
return [
|
|
regular,
|
|
{
|
|
bytes: padding.bytes,
|
|
offsets: incrementOffsetAndChildren(padding.offsets, regular.bytes.length)
|
|
}
|
|
];
|
|
};
|
|
function serializeUint16(value) {
|
|
const buffer = new ArrayBuffer(2);
|
|
const view = new DataView(buffer);
|
|
view.setUint16(0, value);
|
|
return new Uint8Array(buffer);
|
|
}
|
|
|
|
// src/create/iso-base-media/primitives.ts
|
|
var stringsToUint8Array = (str) => {
|
|
return new TextEncoder().encode(str);
|
|
};
|
|
var numberTo32BitUIntOrInt = (num) => {
|
|
return new Uint8Array([
|
|
num >> 24 & 255,
|
|
num >> 16 & 255,
|
|
num >> 8 & 255,
|
|
num & 255
|
|
]);
|
|
};
|
|
var numberTo64BitUIntOrInt = (num) => {
|
|
const bigNum = BigInt(num);
|
|
return new Uint8Array([
|
|
Number(bigNum >> 56n & 0xffn),
|
|
Number(bigNum >> 48n & 0xffn),
|
|
Number(bigNum >> 40n & 0xffn),
|
|
Number(bigNum >> 32n & 0xffn),
|
|
Number(bigNum >> 24n & 0xffn),
|
|
Number(bigNum >> 16n & 0xffn),
|
|
Number(bigNum >> 8n & 0xffn),
|
|
Number(bigNum & 0xffn)
|
|
]);
|
|
};
|
|
var numberTo32BitUIntOrIntLeading128 = (num) => {
|
|
const arr = [
|
|
num >> 24 & 255,
|
|
num >> 16 & 255,
|
|
num >> 8 & 255,
|
|
num & 255
|
|
];
|
|
for (const i in arr) {
|
|
if (arr[i] === 0) {
|
|
arr[i] = 128;
|
|
} else {
|
|
break;
|
|
}
|
|
}
|
|
return new Uint8Array(arr);
|
|
};
|
|
var numberTo16BitUIntOrInt = (num) => {
|
|
return new Uint8Array([num >> 8 & 255, num & 255]);
|
|
};
|
|
var setFixedPointSignedOrUnsigned1616Number = (num) => {
|
|
const val = Math.round(num * 2 ** 16);
|
|
return numberTo32BitUIntOrInt(val);
|
|
};
|
|
var setFixedPointSigned230Number = (num) => {
|
|
const val = Math.round(num * 2 ** 30);
|
|
return numberTo32BitUIntOrInt(val);
|
|
};
|
|
var addSize = (arr) => {
|
|
return combineUint8Arrays([numberTo32BitUIntOrInt(arr.length + 4), arr]);
|
|
};
|
|
var addLeading128Size = (arr) => {
|
|
return combineUint8Arrays([
|
|
numberTo32BitUIntOrIntLeading128(arr.length),
|
|
arr
|
|
]);
|
|
};
|
|
var floatTo16Point1632Bit = (number) => {
|
|
const fixedNumber = Number(number.toFixed(2));
|
|
const result = new Uint8Array(4);
|
|
const tens = Math.floor(fixedNumber / 10);
|
|
const ones = Math.floor(fixedNumber % 10);
|
|
const tenths = Math.floor(fixedNumber * 10 % 10);
|
|
const hundredths = Math.floor(fixedNumber * 100 % 10);
|
|
result[0] = tens;
|
|
result[1] = ones;
|
|
result[2] = tenths;
|
|
result[3] = hundredths;
|
|
return result;
|
|
};
|
|
var floatTo16Point16_16Bit = (number) => {
|
|
const fixedNumber = Number(number.toFixed(2));
|
|
const result = new Uint8Array(2);
|
|
const ones = Math.floor(fixedNumber % 10);
|
|
const tenths = Math.floor(fixedNumber * 10 % 10);
|
|
result[0] = ones;
|
|
result[1] = tenths;
|
|
return result;
|
|
};
|
|
var serializeMatrix = (matrix) => {
|
|
return combineUint8Arrays([
|
|
setFixedPointSignedOrUnsigned1616Number(matrix[0]),
|
|
setFixedPointSignedOrUnsigned1616Number(matrix[1]),
|
|
setFixedPointSigned230Number(matrix[2]),
|
|
setFixedPointSignedOrUnsigned1616Number(matrix[3]),
|
|
setFixedPointSignedOrUnsigned1616Number(matrix[4]),
|
|
setFixedPointSigned230Number(matrix[5]),
|
|
setFixedPointSignedOrUnsigned1616Number(matrix[6]),
|
|
setFixedPointSignedOrUnsigned1616Number(matrix[7]),
|
|
setFixedPointSigned230Number(matrix[8])
|
|
]);
|
|
};
|
|
var stringToPascalString = (str) => {
|
|
const buffer = new Uint8Array(32);
|
|
for (let i = 0;i < Math.min(str.length, 32); i++) {
|
|
buffer[i] = str.charCodeAt(i);
|
|
}
|
|
return buffer;
|
|
};
|
|
var padIsoBaseMediaBytes = (data, totalLength) => {
|
|
if (data.length - 8 > totalLength) {
|
|
throw new Error(`Data is longer than the total length: ${data.length - 8} > ${totalLength}. Set the 'expectedDurationInSeconds' value to avoid this problem: https://www.remotion.dev/docs/webcodecs/convert-media#expecteddurationinseconds`);
|
|
}
|
|
if (data.length - 8 === totalLength) {
|
|
return data;
|
|
}
|
|
return combineUint8Arrays([
|
|
data,
|
|
addSize(combineUint8Arrays([
|
|
stringsToUint8Array("free"),
|
|
new Uint8Array(totalLength - (data.length - 8))
|
|
]))
|
|
]);
|
|
};
|
|
var IDENTITY_MATRIX = [1, 0, 0, 0, 1, 0, 0, 0, 1];
|
|
|
|
// src/create/iso-base-media/create-ftyp.ts
|
|
var createFtyp = ({
|
|
majorBrand,
|
|
minorBrand,
|
|
compatibleBrands
|
|
}) => {
|
|
const type = stringsToUint8Array("ftyp");
|
|
const majorBrandArr = stringsToUint8Array(majorBrand);
|
|
const minorBrandArr = numberTo32BitUIntOrInt(minorBrand);
|
|
const compatibleBrandsArr = combineUint8Arrays(compatibleBrands.map((b) => stringsToUint8Array(b)));
|
|
return addSize(combineUint8Arrays([
|
|
type,
|
|
majorBrandArr,
|
|
minorBrandArr,
|
|
compatibleBrandsArr
|
|
]));
|
|
};
|
|
var createIsoBaseMediaFtyp = ({
|
|
majorBrand,
|
|
minorBrand,
|
|
compatibleBrands
|
|
}) => {
|
|
return createFtyp({ compatibleBrands, majorBrand, minorBrand });
|
|
};
|
|
|
|
// src/create/iso-base-media/mp4-header.ts
|
|
import { VERSION } from "@remotion/media-parser";
|
|
|
|
// src/create/iso-base-media/create-ilst.ts
|
|
var createIlst = (items) => {
|
|
return addSize(combineUint8Arrays([
|
|
stringsToUint8Array("ilst"),
|
|
...items
|
|
]));
|
|
};
|
|
|
|
// src/create/iso-base-media/create-moov.ts
|
|
var createMoov = ({
|
|
mvhd,
|
|
traks,
|
|
udta
|
|
}) => {
|
|
return addSize(combineUint8Arrays([
|
|
stringsToUint8Array("moov"),
|
|
mvhd,
|
|
...traks,
|
|
udta
|
|
]));
|
|
};
|
|
|
|
// src/from-unix-timestamp.ts
|
|
var fromUnixTimestamp = (value) => {
|
|
if (value === null) {
|
|
return 0;
|
|
}
|
|
const baseDate = new Date("1904-01-01T00:00:00Z");
|
|
return Math.floor(value / 1000 - baseDate.getTime() / 1000);
|
|
};
|
|
|
|
// src/create/iso-base-media/create-mvhd.ts
|
|
var createMvhd = ({
|
|
timescale,
|
|
durationInUnits,
|
|
rate,
|
|
volume,
|
|
nextTrackId,
|
|
matrix,
|
|
creationTime,
|
|
modificationTime
|
|
}) => {
|
|
if (matrix.length !== 9) {
|
|
throw new Error("Matrix must be 9 elements long");
|
|
}
|
|
const content = combineUint8Arrays([
|
|
stringsToUint8Array("mvhd"),
|
|
new Uint8Array([0]),
|
|
new Uint8Array([0, 0, 0]),
|
|
creationTime === null ? numberTo32BitUIntOrInt(0) : numberTo32BitUIntOrInt(fromUnixTimestamp(creationTime)),
|
|
modificationTime === null ? numberTo32BitUIntOrInt(0) : numberTo32BitUIntOrInt(fromUnixTimestamp(modificationTime)),
|
|
numberTo32BitUIntOrInt(timescale),
|
|
numberTo32BitUIntOrInt(durationInUnits),
|
|
floatTo16Point1632Bit(rate),
|
|
floatTo16Point16_16Bit(volume),
|
|
new Uint8Array([0, 0]),
|
|
new Uint8Array([0, 0, 0, 0]),
|
|
new Uint8Array([0, 0, 0, 0]),
|
|
serializeMatrix(matrix),
|
|
combineUint8Arrays(new Array(6).fill(new Uint8Array([0, 0, 0, 0]))),
|
|
numberTo32BitUIntOrInt(nextTrackId)
|
|
]);
|
|
return addSize(content);
|
|
};
|
|
|
|
// src/create/iso-base-media/create-udta.ts
|
|
var createUdta = (children) => {
|
|
return addSize(combineUint8Arrays([
|
|
stringsToUint8Array("udta"),
|
|
children
|
|
]));
|
|
};
|
|
|
|
// src/create/iso-base-media/header-length.ts
|
|
var calculateAReasonableMp4HeaderLength = ({
|
|
expectedDurationInSeconds,
|
|
expectedFrameRate
|
|
}) => {
|
|
if (expectedDurationInSeconds === null) {
|
|
return 2048000;
|
|
}
|
|
const assumedFrameRate = expectedFrameRate ?? 60;
|
|
const frameRateMultiplier = assumedFrameRate / 30;
|
|
const bytesPerSecond = 3.7 * 1024 * 1024 / 6000;
|
|
const bytesWithSafetyMargin = bytesPerSecond * 1.2 * frameRateMultiplier;
|
|
const calculatedBytes = Math.max(50 * 1024, Math.ceil(expectedDurationInSeconds * bytesWithSafetyMargin));
|
|
return calculatedBytes;
|
|
};
|
|
|
|
// src/create/iso-base-media/ilst/create-cmt.ts
|
|
var createCmt = (comment) => {
|
|
return addSize(combineUint8Arrays([
|
|
new Uint8Array([169, 99, 109, 116]),
|
|
addSize(combineUint8Arrays([
|
|
stringsToUint8Array("data"),
|
|
new Uint8Array([0, 0]),
|
|
new Uint8Array([0, 1]),
|
|
new Uint8Array([0, 0]),
|
|
new Uint8Array([0, 0]),
|
|
stringsToUint8Array(comment)
|
|
]))
|
|
]));
|
|
};
|
|
|
|
// src/create/iso-base-media/ilst/create-too.ts
|
|
var createToo = (value) => {
|
|
return addSize(combineUint8Arrays([
|
|
new Uint8Array([169, 116, 111, 111]),
|
|
addSize(combineUint8Arrays([
|
|
new Uint8Array([100, 97, 116, 97]),
|
|
new Uint8Array([0, 0]),
|
|
new Uint8Array([0, 1]),
|
|
new Uint8Array([0, 0]),
|
|
new Uint8Array([0, 0]),
|
|
stringsToUint8Array(value)
|
|
]))
|
|
]));
|
|
};
|
|
|
|
// src/create/iso-base-media/trak/mdia/minf/stbl/stsd/create-avcc.ts
|
|
var createAvccBox = (privateData) => {
|
|
if (!privateData) {
|
|
throw new Error("privateData is required");
|
|
}
|
|
return addSize(combineUint8Arrays([
|
|
stringsToUint8Array("avcC"),
|
|
privateData
|
|
]));
|
|
};
|
|
|
|
// src/create/iso-base-media/trak/mdia/minf/stbl/stsd/create-hvcc.ts
|
|
var createHvccBox = (privateData) => {
|
|
if (!privateData) {
|
|
throw new Error("privateData is required");
|
|
}
|
|
return addSize(combineUint8Arrays([
|
|
stringsToUint8Array("hvcC"),
|
|
privateData
|
|
]));
|
|
};
|
|
|
|
// src/create/iso-base-media/trak/mdia/minf/stbl/stsd/create-pasp.ts
|
|
var createPasp = (x, y) => {
|
|
return addSize(combineUint8Arrays([
|
|
stringsToUint8Array("pasp"),
|
|
numberTo32BitUIntOrInt(x),
|
|
numberTo32BitUIntOrInt(y)
|
|
]));
|
|
};
|
|
|
|
// src/create/iso-base-media/codec-specific/avc1.ts
|
|
var createAvc1Data = ({
|
|
avccBox,
|
|
pasp,
|
|
width,
|
|
height,
|
|
horizontalResolution,
|
|
verticalResolution,
|
|
compressorName,
|
|
depth
|
|
}) => {
|
|
return addSize(combineUint8Arrays([
|
|
stringsToUint8Array("avc1"),
|
|
new Uint8Array([0, 0, 0, 0, 0, 0]),
|
|
new Uint8Array([0, 1]),
|
|
new Uint8Array([0, 0]),
|
|
new Uint8Array([0, 0]),
|
|
new Uint8Array([0, 0, 0, 0]),
|
|
new Uint8Array([0, 0, 0, 0]),
|
|
new Uint8Array([0, 0, 0, 0]),
|
|
numberTo16BitUIntOrInt(width),
|
|
numberTo16BitUIntOrInt(height),
|
|
setFixedPointSignedOrUnsigned1616Number(horizontalResolution),
|
|
setFixedPointSignedOrUnsigned1616Number(verticalResolution),
|
|
new Uint8Array([0, 0, 0, 0]),
|
|
numberTo16BitUIntOrInt(1),
|
|
stringToPascalString(compressorName),
|
|
numberTo16BitUIntOrInt(depth),
|
|
numberTo16BitUIntOrInt(-1),
|
|
avccBox,
|
|
pasp
|
|
]));
|
|
};
|
|
|
|
// src/create/iso-base-media/codec-specific/hvc1.ts
|
|
var createHvc1Data = ({
|
|
compressorName,
|
|
depth,
|
|
height,
|
|
horizontalResolution,
|
|
hvccBox,
|
|
pasp,
|
|
verticalResolution,
|
|
width
|
|
}) => {
|
|
return addSize(combineUint8Arrays([
|
|
stringsToUint8Array("hvc1"),
|
|
new Uint8Array([0, 0, 0, 0, 0, 0]),
|
|
new Uint8Array([0, 1]),
|
|
new Uint8Array([0, 0]),
|
|
new Uint8Array([0, 0]),
|
|
new Uint8Array([0, 0, 0, 0]),
|
|
new Uint8Array([0, 0, 0, 0]),
|
|
new Uint8Array([0, 0, 0, 0]),
|
|
numberTo16BitUIntOrInt(width),
|
|
numberTo16BitUIntOrInt(height),
|
|
setFixedPointSignedOrUnsigned1616Number(horizontalResolution),
|
|
setFixedPointSignedOrUnsigned1616Number(verticalResolution),
|
|
new Uint8Array([0, 0, 0, 0]),
|
|
numberTo16BitUIntOrInt(1),
|
|
stringToPascalString(compressorName),
|
|
numberTo16BitUIntOrInt(depth),
|
|
numberTo16BitUIntOrInt(-1),
|
|
hvccBox,
|
|
pasp
|
|
]));
|
|
};
|
|
|
|
// src/create/iso-base-media/codec-specific/mp4a.ts
|
|
var createMp4a = ({
|
|
sampleRate,
|
|
channelCount,
|
|
avgBitrate,
|
|
maxBitrate,
|
|
codecPrivate
|
|
}) => {
|
|
if (!codecPrivate) {
|
|
throw new Error("Need codecPrivate for mp4a");
|
|
}
|
|
const esdsAtom = addSize(combineUint8Arrays([
|
|
stringsToUint8Array("esds"),
|
|
new Uint8Array([0]),
|
|
new Uint8Array([0, 0, 0]),
|
|
new Uint8Array([3]),
|
|
addLeading128Size(combineUint8Arrays([
|
|
numberTo16BitUIntOrInt(2),
|
|
new Uint8Array([0]),
|
|
new Uint8Array([4]),
|
|
addLeading128Size(combineUint8Arrays([
|
|
new Uint8Array([64]),
|
|
new Uint8Array([21]),
|
|
new Uint8Array([0, 0, 0]),
|
|
numberTo32BitUIntOrInt(maxBitrate),
|
|
numberTo32BitUIntOrInt(avgBitrate),
|
|
new Uint8Array([5]),
|
|
addLeading128Size(codecPrivate)
|
|
])),
|
|
new Uint8Array([6]),
|
|
addLeading128Size(new Uint8Array([2]))
|
|
]))
|
|
]));
|
|
return addSize(combineUint8Arrays([
|
|
stringsToUint8Array("mp4a"),
|
|
new Uint8Array([0, 0, 0, 0, 0, 0]),
|
|
numberTo16BitUIntOrInt(1),
|
|
numberTo16BitUIntOrInt(0),
|
|
numberTo16BitUIntOrInt(0),
|
|
new Uint8Array([0, 0, 0, 0]),
|
|
numberTo16BitUIntOrInt(channelCount),
|
|
numberTo16BitUIntOrInt(16),
|
|
numberTo16BitUIntOrInt(0),
|
|
numberTo16BitUIntOrInt(0),
|
|
setFixedPointSignedOrUnsigned1616Number(sampleRate),
|
|
esdsAtom
|
|
]));
|
|
};
|
|
|
|
// src/create/iso-base-media/codec-specific/create-codec-specific-data.ts
|
|
var createCodecSpecificData = (track) => {
|
|
if (track.type === "video") {
|
|
if (track.codec === "h264") {
|
|
if (!track.codecPrivate) {
|
|
return new Uint8Array([]);
|
|
}
|
|
return createAvc1Data({
|
|
avccBox: createAvccBox(track.codecPrivate),
|
|
compressorName: "WebCodecs",
|
|
depth: 24,
|
|
horizontalResolution: 72,
|
|
verticalResolution: 72,
|
|
height: track.height,
|
|
width: track.width,
|
|
pasp: createPasp(1, 1),
|
|
type: "avc1-data"
|
|
});
|
|
}
|
|
if (track.codec === "h265") {
|
|
if (!track.codecPrivate) {
|
|
return new Uint8Array([]);
|
|
}
|
|
return createHvc1Data({
|
|
hvccBox: createHvccBox(track.codecPrivate),
|
|
compressorName: "WebCodecs",
|
|
depth: 24,
|
|
horizontalResolution: 72,
|
|
verticalResolution: 72,
|
|
height: track.height,
|
|
width: track.width,
|
|
pasp: createPasp(1, 1),
|
|
type: "hvc1-data"
|
|
});
|
|
}
|
|
throw new Error("Unsupported codec specific data " + track.codec);
|
|
}
|
|
if (track.type === "audio") {
|
|
return createMp4a({
|
|
type: "mp4a-data",
|
|
avgBitrate: 128 * 1024,
|
|
maxBitrate: 128 * 1024,
|
|
channelCount: track.numberOfChannels,
|
|
sampleRate: track.sampleRate,
|
|
codecPrivate: track.codecPrivate
|
|
});
|
|
}
|
|
throw new Error("Unsupported codec specific data " + track);
|
|
};
|
|
|
|
// src/create/iso-base-media/create-mdia.ts
|
|
var createMdia = ({
|
|
mdhd,
|
|
hdlr,
|
|
minf
|
|
}) => {
|
|
return addSize(combineUint8Arrays([
|
|
stringsToUint8Array("mdia"),
|
|
mdhd,
|
|
hdlr,
|
|
minf
|
|
]));
|
|
};
|
|
|
|
// src/truthy.ts
|
|
function truthy(value) {
|
|
return Boolean(value);
|
|
}
|
|
|
|
// src/create/iso-base-media/create-trak.ts
|
|
var createTrak = ({
|
|
tkhd,
|
|
mdia
|
|
}) => {
|
|
return addSize(combineUint8Arrays([
|
|
stringsToUint8Array("trak"),
|
|
tkhd,
|
|
mdia
|
|
].filter(truthy)));
|
|
};
|
|
|
|
// src/create/iso-base-media/mdia/create-mdhd.ts
|
|
var createMdhd = ({
|
|
creationTime,
|
|
modificationTime,
|
|
timescale,
|
|
duration
|
|
}) => {
|
|
return addSize(combineUint8Arrays([
|
|
stringsToUint8Array("mdhd"),
|
|
new Uint8Array([0]),
|
|
new Uint8Array([0, 0, 0]),
|
|
creationTime === null ? numberTo32BitUIntOrInt(0) : numberTo32BitUIntOrInt(fromUnixTimestamp(creationTime)),
|
|
modificationTime === null ? numberTo32BitUIntOrInt(0) : numberTo32BitUIntOrInt(fromUnixTimestamp(modificationTime)),
|
|
numberTo32BitUIntOrInt(timescale),
|
|
numberTo32BitUIntOrInt(Math.round(duration / 1000 * timescale)),
|
|
new Uint8Array([85, 196]),
|
|
new Uint8Array([0, 0])
|
|
]));
|
|
};
|
|
|
|
// src/create/iso-base-media/trak/create-tkhd.ts
|
|
var TKHD_FLAGS = {
|
|
TRACK_ENABLED: 1,
|
|
TRACK_IN_MOVIE: 2,
|
|
TRACK_IN_PREVIEW: 4,
|
|
TRACK_IN_POSTER: 8
|
|
};
|
|
var createTkhdForAudio = ({
|
|
creationTime,
|
|
modificationTime,
|
|
flags,
|
|
trackId,
|
|
duration,
|
|
volume,
|
|
timescale
|
|
}) => {
|
|
return addSize(combineUint8Arrays([
|
|
stringsToUint8Array("tkhd"),
|
|
new Uint8Array([0]),
|
|
new Uint8Array([0, 0, flags]),
|
|
creationTime === null ? numberTo32BitUIntOrInt(0) : numberTo32BitUIntOrInt(fromUnixTimestamp(creationTime)),
|
|
modificationTime === null ? numberTo32BitUIntOrInt(0) : numberTo32BitUIntOrInt(fromUnixTimestamp(modificationTime)),
|
|
numberTo32BitUIntOrInt(trackId),
|
|
new Uint8Array([0, 0, 0, 0]),
|
|
numberTo32BitUIntOrInt(Math.round(duration / 1000 * timescale)),
|
|
new Uint8Array([0, 0, 0, 0]),
|
|
new Uint8Array([0, 0, 0, 0]),
|
|
new Uint8Array([0, 0]),
|
|
new Uint8Array([0, 1]),
|
|
floatTo16Point16_16Bit(volume),
|
|
new Uint8Array([0, 0]),
|
|
serializeMatrix(IDENTITY_MATRIX),
|
|
setFixedPointSignedOrUnsigned1616Number(0),
|
|
setFixedPointSignedOrUnsigned1616Number(0)
|
|
]));
|
|
};
|
|
var createTkhdForVideo = ({
|
|
creationTime,
|
|
modificationTime,
|
|
duration,
|
|
trackId,
|
|
volume,
|
|
matrix,
|
|
width,
|
|
height,
|
|
flags,
|
|
timescale
|
|
}) => {
|
|
const content = combineUint8Arrays([
|
|
stringsToUint8Array("tkhd"),
|
|
new Uint8Array([0]),
|
|
new Uint8Array([0, 0, flags]),
|
|
creationTime === null ? numberTo32BitUIntOrInt(0) : numberTo32BitUIntOrInt(fromUnixTimestamp(creationTime)),
|
|
modificationTime === null ? numberTo32BitUIntOrInt(0) : numberTo32BitUIntOrInt(fromUnixTimestamp(modificationTime)),
|
|
numberTo32BitUIntOrInt(trackId),
|
|
new Uint8Array([0, 0, 0, 0]),
|
|
numberTo32BitUIntOrInt(duration / 1000 * timescale),
|
|
new Uint8Array([0, 0, 0, 0]),
|
|
new Uint8Array([0, 0, 0, 0]),
|
|
new Uint8Array([0, 0]),
|
|
new Uint8Array([0, 0]),
|
|
floatTo16Point16_16Bit(volume),
|
|
new Uint8Array([0, 0]),
|
|
serializeMatrix(matrix),
|
|
setFixedPointSignedOrUnsigned1616Number(width),
|
|
setFixedPointSignedOrUnsigned1616Number(height)
|
|
]);
|
|
return addSize(content);
|
|
};
|
|
|
|
// src/create/iso-base-media/trak/mdia/minf/create-dinf.ts
|
|
var createDinf = () => {
|
|
return addSize(combineUint8Arrays([
|
|
stringsToUint8Array("dinf"),
|
|
addSize(combineUint8Arrays([
|
|
stringsToUint8Array("dref"),
|
|
new Uint8Array([0]),
|
|
new Uint8Array([0, 0, 0]),
|
|
new Uint8Array([0, 0, 0, 1]),
|
|
addSize(combineUint8Arrays([
|
|
stringsToUint8Array("url "),
|
|
new Uint8Array([0]),
|
|
new Uint8Array([0, 0, 1])
|
|
]))
|
|
]))
|
|
]));
|
|
};
|
|
|
|
// src/create/iso-base-media/trak/mdia/create-minf.ts
|
|
var createMinf = ({
|
|
vmhdAtom,
|
|
stblAtom
|
|
}) => {
|
|
return addSize(combineUint8Arrays([
|
|
stringsToUint8Array("minf"),
|
|
vmhdAtom,
|
|
createDinf(),
|
|
stblAtom
|
|
]));
|
|
};
|
|
|
|
// src/create/iso-base-media/trak/mdia/minf/create-smhd.ts
|
|
var createSmhd = () => {
|
|
return addSize(combineUint8Arrays([
|
|
stringsToUint8Array("smhd"),
|
|
new Uint8Array([0]),
|
|
new Uint8Array([0, 0, 0]),
|
|
new Uint8Array([0, 0]),
|
|
new Uint8Array([0, 0])
|
|
]));
|
|
};
|
|
|
|
// src/create/iso-base-media/trak/mdia/minf/stbl/create-ctts.ts
|
|
var makeEntry = (entry) => {
|
|
return combineUint8Arrays([
|
|
numberTo32BitUIntOrInt(entry.sampleCount),
|
|
numberTo32BitUIntOrInt(entry.sampleOffset)
|
|
]);
|
|
};
|
|
var createCttsBox = (samplePositions) => {
|
|
const offsets = samplePositions.map((s) => s.timestamp - s.decodingTimestamp);
|
|
const entries = [];
|
|
let lastOffset = null;
|
|
for (const offset of offsets) {
|
|
if (lastOffset === offset) {
|
|
entries[entries.length - 1].sampleCount++;
|
|
} else {
|
|
entries.push({
|
|
sampleCount: 1,
|
|
sampleOffset: offset
|
|
});
|
|
}
|
|
lastOffset = offset;
|
|
}
|
|
const needsCtts = entries.length > 0 && entries.some((e) => e.sampleOffset !== 0);
|
|
if (!needsCtts) {
|
|
return null;
|
|
}
|
|
return addSize(combineUint8Arrays([
|
|
stringsToUint8Array("ctts"),
|
|
new Uint8Array([0]),
|
|
new Uint8Array([0, 0, 0]),
|
|
numberTo32BitUIntOrInt(entries.length),
|
|
...entries.map((e) => makeEntry(e))
|
|
]));
|
|
};
|
|
|
|
// src/create/iso-base-media/trak/mdia/minf/stbl/create-stco.ts
|
|
var createStcoAtom = (samplePositions) => {
|
|
const chunkOffsets = [];
|
|
let lastChunk;
|
|
let needs64Bit = false;
|
|
for (const sample of samplePositions) {
|
|
if (lastChunk !== sample.chunk) {
|
|
chunkOffsets.push(sample.offset);
|
|
}
|
|
if (sample.offset > 2 ** 32) {
|
|
needs64Bit = true;
|
|
}
|
|
lastChunk = sample.chunk;
|
|
}
|
|
return addSize(combineUint8Arrays([
|
|
stringsToUint8Array(needs64Bit ? "co64" : "stco"),
|
|
new Uint8Array([0]),
|
|
new Uint8Array([0, 0, 0]),
|
|
numberTo32BitUIntOrInt(chunkOffsets.length),
|
|
combineUint8Arrays(chunkOffsets.map((offset) => needs64Bit ? numberTo64BitUIntOrInt(offset) : numberTo32BitUIntOrInt(offset)))
|
|
]));
|
|
};
|
|
|
|
// src/create/iso-base-media/trak/mdia/minf/stbl/create-stsc.ts
|
|
var createEntry = (entry) => {
|
|
return combineUint8Arrays([
|
|
numberTo32BitUIntOrInt(entry.firstChunk),
|
|
numberTo32BitUIntOrInt(entry.samplesPerChunk),
|
|
numberTo32BitUIntOrInt(entry.sampleDescriptionIndex)
|
|
]);
|
|
};
|
|
var createStsc = (samplePositions) => {
|
|
const entries = [];
|
|
const deduplicateLastEntry = () => {
|
|
const lastEntry = entries[entries.length - 1];
|
|
const secondToLastEntry = entries[entries.length - 2];
|
|
if (lastEntry && secondToLastEntry && lastEntry.samplesPerChunk === secondToLastEntry.samplesPerChunk && lastEntry.sampleDescriptionIndex === secondToLastEntry.sampleDescriptionIndex) {
|
|
const lastIndex = entries.length - 1;
|
|
entries.length = lastIndex;
|
|
}
|
|
};
|
|
let lastChunk;
|
|
for (const samplePosition of samplePositions) {
|
|
if (samplePosition.chunk === lastChunk) {
|
|
entries[entries.length - 1].samplesPerChunk++;
|
|
} else {
|
|
deduplicateLastEntry();
|
|
entries.push({
|
|
firstChunk: samplePosition.chunk,
|
|
samplesPerChunk: 1,
|
|
sampleDescriptionIndex: 1
|
|
});
|
|
lastChunk = samplePosition.chunk;
|
|
}
|
|
}
|
|
deduplicateLastEntry();
|
|
return addSize(combineUint8Arrays([
|
|
stringsToUint8Array("stsc"),
|
|
new Uint8Array([0]),
|
|
new Uint8Array([0, 0, 0]),
|
|
numberTo32BitUIntOrInt(entries.length),
|
|
...entries.map((e) => createEntry(e))
|
|
]));
|
|
};
|
|
|
|
// src/create/iso-base-media/trak/mdia/minf/stbl/create-stss.ts
|
|
var createStss = (samplePositions) => {
|
|
const samples = samplePositions.map((sample, i) => [sample.isKeyframe, i]).filter((s) => s[0]).map((s) => s[1] + 1);
|
|
return addSize(combineUint8Arrays([
|
|
stringsToUint8Array("stss"),
|
|
new Uint8Array([0]),
|
|
new Uint8Array([0, 0, 0]),
|
|
numberTo32BitUIntOrInt(samples.length),
|
|
...samples.map((sample) => numberTo32BitUIntOrInt(sample))
|
|
]));
|
|
};
|
|
|
|
// src/create/iso-base-media/trak/mdia/minf/stbl/create-stsz.ts
|
|
var createStsz = (samplePositions) => {
|
|
const sampleSizes = samplePositions.map((samplePosition) => samplePosition.size);
|
|
return addSize(combineUint8Arrays([
|
|
stringsToUint8Array("stsz"),
|
|
new Uint8Array([0]),
|
|
new Uint8Array([0, 0, 0]),
|
|
numberTo32BitUIntOrInt(0),
|
|
numberTo32BitUIntOrInt(sampleSizes.length),
|
|
...sampleSizes.map((size) => numberTo32BitUIntOrInt(size))
|
|
]));
|
|
};
|
|
|
|
// src/create/iso-base-media/trak/mdia/minf/stbl/create-stts.ts
|
|
var makeEntry2 = (entry) => {
|
|
if (entry.sampleOffset < 0) {
|
|
throw new Error("negative sample offset in stts " + entry.sampleOffset);
|
|
}
|
|
return combineUint8Arrays([
|
|
numberTo32BitUIntOrInt(entry.sampleCount),
|
|
numberTo32BitUIntOrInt(entry.sampleOffset)
|
|
]);
|
|
};
|
|
var createSttsAtom = (samplePositions) => {
|
|
let lastDuration = null;
|
|
const durations = samplePositions.map((_, i, a) => {
|
|
if (a[i].duration === undefined || a[i].duration === 0) {
|
|
if (a[i + 1] === undefined) {
|
|
return a[i].decodingTimestamp - (a[i - 1]?.decodingTimestamp ?? a[i].decodingTimestamp);
|
|
}
|
|
return a[i + 1].decodingTimestamp - a[i].decodingTimestamp;
|
|
}
|
|
return a[i].duration;
|
|
});
|
|
const entries = [];
|
|
for (const duration of durations) {
|
|
if (duration === lastDuration) {
|
|
entries[entries.length - 1].sampleCount++;
|
|
} else {
|
|
entries.push({
|
|
sampleCount: 1,
|
|
sampleOffset: duration
|
|
});
|
|
}
|
|
lastDuration = duration;
|
|
}
|
|
return addSize(combineUint8Arrays([
|
|
stringsToUint8Array("stts"),
|
|
new Uint8Array([0]),
|
|
new Uint8Array([0, 0, 0]),
|
|
numberTo32BitUIntOrInt(entries.length),
|
|
...entries.map((e) => makeEntry2(e))
|
|
]));
|
|
};
|
|
|
|
// src/create/iso-base-media/trak/mdia/minf/stbl/stsd/create-avc1.ts
|
|
var createStsdData = (codecSpecificData) => {
|
|
return addSize(combineUint8Arrays([
|
|
stringsToUint8Array("stsd"),
|
|
new Uint8Array([0]),
|
|
new Uint8Array([0, 0, 0]),
|
|
new Uint8Array([0, 0, 0, 1]),
|
|
codecSpecificData
|
|
]));
|
|
};
|
|
|
|
// src/create/iso-base-media/trak/mdia/minf/create-stbl.ts
|
|
var createStbl = ({
|
|
samplePositions,
|
|
codecSpecificData,
|
|
isVideo
|
|
}) => {
|
|
const sorted = samplePositions.slice().sort((a, b) => a.decodingTimestamp - b.decodingTimestamp);
|
|
return addSize(combineUint8Arrays([
|
|
stringsToUint8Array("stbl"),
|
|
createStsdData(codecSpecificData),
|
|
createSttsAtom(sorted),
|
|
isVideo ? createStss(samplePositions) : null,
|
|
createCttsBox(samplePositions),
|
|
createStsc(samplePositions),
|
|
createStsz(samplePositions),
|
|
createStcoAtom(samplePositions),
|
|
isVideo ? null : new Uint8Array([
|
|
0,
|
|
0,
|
|
0,
|
|
26,
|
|
115,
|
|
103,
|
|
112,
|
|
100,
|
|
1,
|
|
0,
|
|
0,
|
|
0,
|
|
114,
|
|
111,
|
|
108,
|
|
108,
|
|
0,
|
|
0,
|
|
0,
|
|
2,
|
|
0,
|
|
0,
|
|
0,
|
|
1,
|
|
255,
|
|
255,
|
|
0,
|
|
0,
|
|
0,
|
|
28,
|
|
115,
|
|
98,
|
|
103,
|
|
112,
|
|
0,
|
|
0,
|
|
0,
|
|
0,
|
|
114,
|
|
111,
|
|
108,
|
|
108,
|
|
0,
|
|
0,
|
|
0,
|
|
1,
|
|
0,
|
|
0,
|
|
10,
|
|
25,
|
|
0,
|
|
0,
|
|
0,
|
|
1
|
|
])
|
|
].filter(truthy)));
|
|
};
|
|
|
|
// src/create/iso-base-media/trak/mdia/minf/create-vmhd.ts
|
|
var createVmhd = () => {
|
|
return addSize(combineUint8Arrays([
|
|
stringsToUint8Array("vmhd"),
|
|
new Uint8Array([0]),
|
|
new Uint8Array([0, 0, 1]),
|
|
new Uint8Array([0, 0]),
|
|
new Uint8Array([0, 0, 0, 0, 0, 0])
|
|
]));
|
|
};
|
|
|
|
// src/create/iso-base-media/udta/meta/create-hdlr.ts
|
|
var createHdlr = (type) => {
|
|
return addSize(combineUint8Arrays([
|
|
stringsToUint8Array("hdlr"),
|
|
new Uint8Array([0]),
|
|
new Uint8Array([0, 0, 0]),
|
|
new Uint8Array([0, 0, 0, 0]),
|
|
stringsToUint8Array(type === "mdir" ? "mdir" : type === "video" ? "vide" : "soun"),
|
|
type === "mdir" ? numberTo32BitUIntOrInt(1634758764) : new Uint8Array([0, 0, 0, 0]),
|
|
new Uint8Array([0, 0, 0, 0]),
|
|
new Uint8Array([0, 0, 0, 0]),
|
|
stringsToUint8Array(type === "mdir" ? "\x00" : type === "video" ? "VideoHandler\x00" : "SoundHandler\x00")
|
|
]));
|
|
};
|
|
|
|
// src/create/iso-base-media/serialize-track.ts
|
|
var serializeTrack = ({
|
|
track,
|
|
durationInUnits,
|
|
samplePositions,
|
|
timescale
|
|
}) => {
|
|
if (track.codec !== "h264" && track.codec !== "h265" && track.codec !== "aac") {
|
|
throw new Error("Currently only H.264 and AAC is supported");
|
|
}
|
|
return createTrak({
|
|
tkhd: track.codec === "aac" ? createTkhdForAudio({
|
|
creationTime: Date.now(),
|
|
flags: TKHD_FLAGS.TRACK_ENABLED | TKHD_FLAGS.TRACK_IN_MOVIE,
|
|
modificationTime: Date.now(),
|
|
duration: durationInUnits,
|
|
trackId: track.trackNumber,
|
|
volume: 1,
|
|
timescale
|
|
}) : track.type === "video" ? createTkhdForVideo({
|
|
creationTime: Date.now(),
|
|
modificationTime: Date.now(),
|
|
duration: durationInUnits,
|
|
flags: TKHD_FLAGS.TRACK_ENABLED | TKHD_FLAGS.TRACK_IN_MOVIE,
|
|
height: track.height,
|
|
width: track.width,
|
|
matrix: IDENTITY_MATRIX,
|
|
trackId: track.trackNumber,
|
|
volume: 0,
|
|
timescale
|
|
}) : new Uint8Array(stringsToUint8Array("wrong")),
|
|
mdia: createMdia({
|
|
mdhd: createMdhd({
|
|
creationTime: null,
|
|
modificationTime: null,
|
|
duration: durationInUnits,
|
|
timescale: track.timescale
|
|
}),
|
|
hdlr: track.type === "video" ? createHdlr("video") : createHdlr("audio"),
|
|
minf: createMinf({
|
|
stblAtom: createStbl({
|
|
samplePositions,
|
|
isVideo: track.type === "video",
|
|
codecSpecificData: createCodecSpecificData(track)
|
|
}),
|
|
vmhdAtom: track.type === "audio" ? createSmhd() : createVmhd()
|
|
})
|
|
})
|
|
});
|
|
};
|
|
|
|
// src/create/iso-base-media/udta/create-meta.ts
|
|
var createMeta = ({
|
|
hdlr,
|
|
ilst
|
|
}) => {
|
|
return addSize(combineUint8Arrays([
|
|
stringsToUint8Array("meta"),
|
|
new Uint8Array([0]),
|
|
new Uint8Array([0, 0, 0]),
|
|
hdlr,
|
|
ilst
|
|
]));
|
|
};
|
|
|
|
// src/create/iso-base-media/mp4-header.ts
|
|
var createPaddedMoovAtom = ({
|
|
durationInUnits,
|
|
trackInfo,
|
|
timescale,
|
|
expectedDurationInSeconds,
|
|
logLevel,
|
|
expectedFrameRate
|
|
}) => {
|
|
const headerLength = calculateAReasonableMp4HeaderLength({
|
|
expectedDurationInSeconds,
|
|
expectedFrameRate
|
|
});
|
|
if (expectedDurationInSeconds !== null) {
|
|
Log.verbose(logLevel, `Expecting duration of the video to be ${expectedDurationInSeconds} seconds, allocating ${headerLength} bytes for the MP4 header.`);
|
|
} else {
|
|
Log.verbose(logLevel, `No duration was provided, allocating ${headerLength} bytes for the MP4 header.`);
|
|
}
|
|
return padIsoBaseMediaBytes(createMoov({
|
|
mvhd: createMvhd({
|
|
timescale,
|
|
durationInUnits,
|
|
matrix: IDENTITY_MATRIX,
|
|
nextTrackId: trackInfo.map((t) => t.track.trackNumber).reduce((a, b) => Math.max(a, b), 0) + 1,
|
|
rate: 1,
|
|
volume: 1,
|
|
creationTime: Date.now(),
|
|
modificationTime: Date.now()
|
|
}),
|
|
traks: trackInfo.map((track) => {
|
|
return serializeTrack({
|
|
timescale,
|
|
track: track.track,
|
|
durationInUnits,
|
|
samplePositions: track.samplePositions
|
|
});
|
|
}),
|
|
udta: createUdta(createMeta({
|
|
hdlr: createHdlr("mdir"),
|
|
ilst: createIlst([
|
|
createToo("WebCodecs"),
|
|
createCmt(`Made with @remotion/webcodecs ${VERSION}`)
|
|
])
|
|
}))
|
|
}), headerLength);
|
|
};
|
|
|
|
// src/create/iso-base-media/create-iso-base-media.ts
|
|
var CONTAINER_TIMESCALE = 1000;
|
|
var createIsoBaseMedia = async ({
|
|
writer,
|
|
onBytesProgress,
|
|
onMillisecondsProgress,
|
|
logLevel,
|
|
filename,
|
|
progressTracker,
|
|
expectedDurationInSeconds,
|
|
expectedFrameRate
|
|
}) => {
|
|
const header = createIsoBaseMediaFtyp({
|
|
compatibleBrands: ["isom", "iso2", "avc1", "mp42"],
|
|
majorBrand: "isom",
|
|
minorBrand: 512
|
|
});
|
|
const w = await writer.createContent({
|
|
filename,
|
|
mimeType: "video/mp4",
|
|
logLevel
|
|
});
|
|
await w.write(header);
|
|
let globalDurationInUnits = 0;
|
|
const lowestTrackTimestamps = {};
|
|
const trackDurations = {};
|
|
const currentTracks = [];
|
|
const samplePositions = [];
|
|
const sampleChunkIndices = [];
|
|
const moovOffset = w.getWrittenByteCount();
|
|
const getPaddedMoovAtom = () => {
|
|
return createPaddedMoovAtom({
|
|
durationInUnits: globalDurationInUnits,
|
|
trackInfo: currentTracks.map((track) => {
|
|
return {
|
|
track,
|
|
durationInUnits: trackDurations[track.trackNumber] ?? 0,
|
|
samplePositions: samplePositions[track.trackNumber] ?? [],
|
|
timescale: track.timescale
|
|
};
|
|
}),
|
|
timescale: CONTAINER_TIMESCALE,
|
|
expectedDurationInSeconds,
|
|
logLevel,
|
|
expectedFrameRate
|
|
});
|
|
};
|
|
await w.write(getPaddedMoovAtom());
|
|
let mdatSize = 8;
|
|
const mdatSizeOffset = w.getWrittenByteCount();
|
|
await w.write(combineUint8Arrays([
|
|
numberTo32BitUIntOrInt(mdatSize),
|
|
stringsToUint8Array("mdat")
|
|
]));
|
|
const updateMdatSize = async () => {
|
|
await w.updateDataAt(mdatSizeOffset, numberTo32BitUIntOrInt(mdatSize));
|
|
onBytesProgress(w.getWrittenByteCount());
|
|
};
|
|
const operationProm = { current: Promise.resolve() };
|
|
const updateMoov = async () => {
|
|
await w.updateDataAt(moovOffset, getPaddedMoovAtom());
|
|
onBytesProgress(w.getWrittenByteCount());
|
|
};
|
|
const addCodecPrivateToTrack = ({
|
|
trackNumber,
|
|
codecPrivate
|
|
}) => {
|
|
currentTracks.forEach((track) => {
|
|
if (track.trackNumber === trackNumber) {
|
|
track.codecPrivate = codecPrivate;
|
|
}
|
|
});
|
|
};
|
|
let lastChunkWasVideo = false;
|
|
const addSample = async ({
|
|
chunk,
|
|
trackNumber,
|
|
isVideo,
|
|
codecPrivate
|
|
}) => {
|
|
const position = w.getWrittenByteCount();
|
|
await w.write(chunk.data);
|
|
mdatSize += chunk.data.length;
|
|
onBytesProgress(w.getWrittenByteCount());
|
|
progressTracker.setPossibleLowestTimestamp(Math.min(chunk.timestamp, chunk.decodingTimestamp ?? Infinity));
|
|
progressTracker.updateTrackProgress(trackNumber, chunk.timestamp);
|
|
if (codecPrivate) {
|
|
addCodecPrivateToTrack({ trackNumber, codecPrivate });
|
|
}
|
|
const currentTrack = currentTracks.find((t) => t.trackNumber === trackNumber);
|
|
if (!currentTrack) {
|
|
throw new Error(`Tried to add sample to track ${trackNumber}, but it doesn't exist`);
|
|
}
|
|
if (!lowestTrackTimestamps[trackNumber] || chunk.timestamp < lowestTrackTimestamps[trackNumber]) {
|
|
lowestTrackTimestamps[trackNumber] = chunk.timestamp;
|
|
}
|
|
if (typeof lowestTrackTimestamps[trackNumber] !== "number") {
|
|
throw new Error(`Tried to add sample to track ${trackNumber}, but it has no timestamp`);
|
|
}
|
|
const newDurationInMicroSeconds = chunk.timestamp + (chunk.duration ?? 0) - lowestTrackTimestamps[trackNumber];
|
|
const newDurationInTrackTimeUnits = Math.round(newDurationInMicroSeconds / (1e6 / currentTrack.timescale));
|
|
trackDurations[trackNumber] = newDurationInTrackTimeUnits;
|
|
const newDurationInMilliseconds = Math.round(newDurationInMicroSeconds / 1e6 * CONTAINER_TIMESCALE);
|
|
if (newDurationInMilliseconds > globalDurationInUnits) {
|
|
globalDurationInUnits = newDurationInMilliseconds;
|
|
onMillisecondsProgress(newDurationInMilliseconds);
|
|
}
|
|
if (!samplePositions[trackNumber]) {
|
|
samplePositions[trackNumber] = [];
|
|
}
|
|
if (typeof sampleChunkIndices[trackNumber] === "undefined") {
|
|
sampleChunkIndices[trackNumber] = 0;
|
|
}
|
|
if (isVideo && chunk.type === "key") {
|
|
sampleChunkIndices[trackNumber]++;
|
|
} else if (!isVideo && samplePositions[trackNumber].length % 22 === 0) {
|
|
sampleChunkIndices[trackNumber]++;
|
|
} else if (lastChunkWasVideo !== isVideo) {
|
|
sampleChunkIndices[trackNumber]++;
|
|
}
|
|
const samplePositionToAdd = {
|
|
isKeyframe: chunk.type === "key",
|
|
offset: position,
|
|
chunk: sampleChunkIndices[trackNumber],
|
|
timestamp: Math.round(chunk.timestamp / 1e6 * currentTrack.timescale),
|
|
decodingTimestamp: Math.round(chunk.decodingTimestamp / 1e6 * currentTrack.timescale),
|
|
duration: Math.round((chunk.duration ?? 0) / 1e6 * currentTrack.timescale),
|
|
size: chunk.data.length,
|
|
bigEndian: false,
|
|
chunkSize: null
|
|
};
|
|
lastChunkWasVideo = isVideo;
|
|
samplePositions[trackNumber].push(samplePositionToAdd);
|
|
};
|
|
const addTrack = (track) => {
|
|
const trackNumber = currentTracks.length + 1;
|
|
currentTracks.push({ ...track, trackNumber });
|
|
progressTracker.registerTrack(trackNumber);
|
|
return Promise.resolve({ trackNumber });
|
|
};
|
|
const waitForFinishPromises = [];
|
|
return {
|
|
getBlob: () => {
|
|
return w.getBlob();
|
|
},
|
|
remove: async () => {
|
|
await w.remove();
|
|
},
|
|
addSample: ({ chunk, trackNumber, isVideo, codecPrivate }) => {
|
|
operationProm.current = operationProm.current.then(() => {
|
|
return addSample({
|
|
chunk,
|
|
trackNumber,
|
|
isVideo,
|
|
codecPrivate
|
|
});
|
|
});
|
|
return operationProm.current;
|
|
},
|
|
addTrack: (track) => {
|
|
operationProm.current = operationProm.current.then(() => addTrack(track));
|
|
return operationProm.current;
|
|
},
|
|
updateTrackSampleRate: ({ sampleRate, trackNumber }) => {
|
|
currentTracks.forEach((track) => {
|
|
if (track.trackNumber === trackNumber) {
|
|
if (track.type !== "audio") {
|
|
throw new Error(`Tried to update sample rate of track ${trackNumber}, but it's not an audio track`);
|
|
}
|
|
track.sampleRate = sampleRate;
|
|
}
|
|
});
|
|
},
|
|
addWaitForFinishPromise: (promise) => {
|
|
waitForFinishPromises.push(promise);
|
|
},
|
|
async waitForFinish() {
|
|
MediaParserInternals3.Log.verbose(logLevel, "All write operations queued. Waiting for finish...");
|
|
await Promise.all(waitForFinishPromises.map((p) => p()));
|
|
MediaParserInternals3.Log.verbose(logLevel, "Cleanup tasks executed");
|
|
await operationProm.current;
|
|
await updateMoov();
|
|
await updateMdatSize();
|
|
MediaParserInternals3.Log.verbose(logLevel, "All write operations done. Waiting for finish...");
|
|
await w.finish();
|
|
}
|
|
};
|
|
};
|
|
|
|
// src/create/matroska/create-matroska-media.ts
|
|
import { MediaParserInternals as MediaParserInternals5 } from "@remotion/media-parser";
|
|
|
|
// src/create/matroska/cluster.ts
|
|
import {
|
|
MediaParserInternals as MediaParserInternals4
|
|
} from "@remotion/media-parser";
|
|
|
|
// src/create/matroska/cluster-segment.ts
|
|
var CLUSTER_MIN_VINT_WIDTH = 8;
|
|
var createClusterSegment = (timestamp) => {
|
|
return makeMatroskaBytes({
|
|
type: "Cluster",
|
|
value: [
|
|
{
|
|
type: "Timestamp",
|
|
minVintWidth: null,
|
|
value: {
|
|
value: timestamp,
|
|
byteLength: null
|
|
}
|
|
}
|
|
],
|
|
minVintWidth: CLUSTER_MIN_VINT_WIDTH
|
|
});
|
|
};
|
|
var makeSimpleBlock = ({
|
|
bytes,
|
|
trackNumber,
|
|
timecodeRelativeToCluster,
|
|
keyframe,
|
|
invisible,
|
|
lacing
|
|
}) => {
|
|
const simpleBlockHeader = matroskaToHex("0xa3");
|
|
const headerByte = Number(keyframe) << 7 | Number(invisible) << 3 | lacing << 1;
|
|
const body = combineUint8Arrays([
|
|
getVariableInt(trackNumber, null),
|
|
serializeUint16(timecodeRelativeToCluster),
|
|
new Uint8Array([headerByte]),
|
|
bytes
|
|
]);
|
|
return combineUint8Arrays([
|
|
simpleBlockHeader,
|
|
getVariableInt(body.length, null),
|
|
body
|
|
]);
|
|
};
|
|
|
|
// src/create/matroska/cluster.ts
|
|
var maxClusterTimestamp = 2 ** 15;
|
|
var timestampToClusterTimestamp = (timestamp, timescale) => {
|
|
return Math.round(timestamp / timescale * 1000);
|
|
};
|
|
var canFitInCluster = ({
|
|
clusterStartTimestamp,
|
|
chunk,
|
|
timescale
|
|
}) => {
|
|
const timecodeRelativeToCluster = timestampToClusterTimestamp(chunk.timestamp, timescale) - timestampToClusterTimestamp(clusterStartTimestamp, timescale);
|
|
if (timecodeRelativeToCluster < 0) {
|
|
throw new Error(`timecodeRelativeToCluster is negative, tried to add ${chunk.timestamp} to ${clusterStartTimestamp}`);
|
|
}
|
|
return timecodeRelativeToCluster <= maxClusterTimestamp;
|
|
};
|
|
var makeCluster = async ({
|
|
writer,
|
|
clusterStartTimestamp,
|
|
timescale,
|
|
logLevel
|
|
}) => {
|
|
Log.verbose(logLevel, `Making new Matroska cluster with timestamp ${clusterStartTimestamp}`);
|
|
const cluster = createClusterSegment(timestampToClusterTimestamp(clusterStartTimestamp, timescale));
|
|
const clusterVIntPosition = writer.getWrittenByteCount() + cluster.offsets.offset + matroskaToHex(MediaParserInternals4.matroskaElements.Cluster).byteLength;
|
|
let clusterSize = cluster.bytes.byteLength - matroskaToHex(MediaParserInternals4.matroskaElements.Cluster).byteLength - CLUSTER_MIN_VINT_WIDTH;
|
|
await writer.write(cluster.bytes);
|
|
const addSample = async (chunk, trackNumber) => {
|
|
const timecodeRelativeToCluster = timestampToClusterTimestamp(chunk.timestamp, timescale) - timestampToClusterTimestamp(clusterStartTimestamp, timescale);
|
|
if (!canFitInCluster({ clusterStartTimestamp, chunk, timescale })) {
|
|
throw new Error(`timecodeRelativeToCluster is too big: ${timecodeRelativeToCluster} > ${maxClusterTimestamp}`);
|
|
}
|
|
const keyframe = chunk.type === "key";
|
|
const simpleBlock = makeSimpleBlock({
|
|
bytes: chunk.data,
|
|
invisible: false,
|
|
keyframe,
|
|
lacing: 0,
|
|
trackNumber,
|
|
timecodeRelativeToCluster
|
|
});
|
|
clusterSize += simpleBlock.byteLength;
|
|
await writer.updateDataAt(clusterVIntPosition, getVariableInt(clusterSize, CLUSTER_MIN_VINT_WIDTH));
|
|
await writer.write(simpleBlock);
|
|
return { timecodeRelativeToCluster };
|
|
};
|
|
const shouldMakeNewCluster = ({
|
|
isVideo,
|
|
chunk,
|
|
newT
|
|
}) => {
|
|
const newTimestamp = timestampToClusterTimestamp(newT, timescale);
|
|
const oldTimestamp = timestampToClusterTimestamp(clusterStartTimestamp, timescale);
|
|
const canFit = canFitInCluster({
|
|
chunk,
|
|
clusterStartTimestamp,
|
|
timescale
|
|
});
|
|
if (!canFit) {
|
|
Log.verbose(logLevel, `Cannot fit ${chunk.timestamp} in cluster ${clusterStartTimestamp}. Creating new cluster`);
|
|
return true;
|
|
}
|
|
const keyframe = chunk.type === "key";
|
|
return newTimestamp - oldTimestamp >= 2000 && keyframe && isVideo;
|
|
};
|
|
return {
|
|
addSample,
|
|
shouldMakeNewCluster,
|
|
startTimestamp: clusterStartTimestamp
|
|
};
|
|
};
|
|
|
|
// src/create/matroska/make-duration-with-padding.ts
|
|
var makeDurationWithPadding = (newDuration) => {
|
|
return makeMatroskaBytes({
|
|
type: "Duration",
|
|
value: {
|
|
value: newDuration,
|
|
size: "64"
|
|
},
|
|
minVintWidth: 8
|
|
});
|
|
};
|
|
|
|
// src/create/matroska/matroska-cues.ts
|
|
var createMatroskaCues = (cues) => {
|
|
if (cues.length === 0) {
|
|
return null;
|
|
}
|
|
return makeMatroskaBytes({
|
|
type: "Cues",
|
|
minVintWidth: null,
|
|
value: cues.map((cue) => {
|
|
return {
|
|
type: "CuePoint",
|
|
value: [
|
|
{
|
|
type: "CueTime",
|
|
minVintWidth: null,
|
|
value: {
|
|
value: cue.time,
|
|
byteLength: null
|
|
}
|
|
},
|
|
{
|
|
type: "CueTrackPositions",
|
|
value: [
|
|
{
|
|
type: "CueTrack",
|
|
minVintWidth: null,
|
|
value: {
|
|
value: cue.trackNumber,
|
|
byteLength: null
|
|
}
|
|
},
|
|
{
|
|
type: "CueClusterPosition",
|
|
minVintWidth: null,
|
|
value: {
|
|
value: cue.clusterPosition,
|
|
byteLength: null
|
|
}
|
|
}
|
|
],
|
|
minVintWidth: null
|
|
}
|
|
],
|
|
minVintWidth: null
|
|
};
|
|
})
|
|
});
|
|
};
|
|
|
|
// src/create/matroska/matroska-header.ts
|
|
var makeMatroskaHeader = () => {
|
|
return makeMatroskaBytes({
|
|
type: "Header",
|
|
value: [
|
|
{
|
|
minVintWidth: null,
|
|
type: "EBMLVersion",
|
|
value: {
|
|
value: 1,
|
|
byteLength: null
|
|
}
|
|
},
|
|
{
|
|
minVintWidth: null,
|
|
type: "EBMLReadVersion",
|
|
value: {
|
|
value: 1,
|
|
byteLength: null
|
|
}
|
|
},
|
|
{
|
|
type: "EBMLMaxIDLength",
|
|
value: {
|
|
byteLength: null,
|
|
value: 4
|
|
},
|
|
minVintWidth: null
|
|
},
|
|
{
|
|
type: "EBMLMaxSizeLength",
|
|
value: {
|
|
byteLength: null,
|
|
value: 8
|
|
},
|
|
minVintWidth: null
|
|
},
|
|
{
|
|
type: "DocType",
|
|
value: "webm",
|
|
minVintWidth: null
|
|
},
|
|
{
|
|
type: "DocTypeVersion",
|
|
value: {
|
|
byteLength: null,
|
|
value: 4
|
|
},
|
|
minVintWidth: null
|
|
},
|
|
{
|
|
type: "DocTypeReadVersion",
|
|
value: {
|
|
byteLength: null,
|
|
value: 2
|
|
},
|
|
minVintWidth: null
|
|
}
|
|
],
|
|
minVintWidth: null
|
|
});
|
|
};
|
|
|
|
// src/create/matroska/matroska-info.ts
|
|
var makeMatroskaInfo = ({ timescale }) => {
|
|
return makeMatroskaBytes({
|
|
type: "Info",
|
|
value: [
|
|
{
|
|
type: "TimestampScale",
|
|
value: {
|
|
value: timescale,
|
|
byteLength: null
|
|
},
|
|
minVintWidth: null
|
|
},
|
|
{
|
|
type: "MuxingApp",
|
|
value: "@remotion/webcodecs",
|
|
minVintWidth: null
|
|
},
|
|
{
|
|
type: "WritingApp",
|
|
value: "@remotion/webcodecs",
|
|
minVintWidth: null
|
|
},
|
|
makeDurationWithPadding(0)
|
|
],
|
|
minVintWidth: null
|
|
});
|
|
};
|
|
|
|
// src/create/matroska/matroska-seek.ts
|
|
var createMatroskaSeekHead = (seeks) => {
|
|
return padMatroskaBytes(makeMatroskaBytes({
|
|
type: "SeekHead",
|
|
minVintWidth: null,
|
|
value: seeks.map((seek) => {
|
|
return {
|
|
type: "Seek",
|
|
minVintWidth: null,
|
|
value: [
|
|
{
|
|
type: "SeekID",
|
|
minVintWidth: null,
|
|
value: seek.hexString
|
|
},
|
|
{
|
|
type: "SeekPosition",
|
|
minVintWidth: null,
|
|
value: {
|
|
value: seek.byte,
|
|
byteLength: null
|
|
}
|
|
}
|
|
]
|
|
};
|
|
})
|
|
}), 200);
|
|
};
|
|
|
|
// src/create/matroska/matroska-segment.ts
|
|
var MATROSKA_SEGMENT_MIN_VINT_WIDTH = 8;
|
|
var createMatroskaSegment = (children) => {
|
|
return makeMatroskaBytes({
|
|
type: "Segment",
|
|
value: children,
|
|
minVintWidth: MATROSKA_SEGMENT_MIN_VINT_WIDTH
|
|
});
|
|
};
|
|
|
|
// src/create/matroska/color.ts
|
|
var getRangeValue = ({
|
|
transferCharacteristics,
|
|
matrixCoefficients,
|
|
fullRange
|
|
}) => {
|
|
return transferCharacteristics && matrixCoefficients ? 3 : fullRange === true ? 2 : fullRange === false ? 1 : 0;
|
|
};
|
|
var getPrimariesValue = (primaries) => {
|
|
if (primaries === null) {
|
|
return null;
|
|
}
|
|
if (primaries === "bt709") {
|
|
return 1;
|
|
}
|
|
if (primaries === "bt470bg") {
|
|
return 5;
|
|
}
|
|
if (primaries === "smpte170m") {
|
|
return 6;
|
|
}
|
|
if (primaries === "bt2020") {
|
|
return 9;
|
|
}
|
|
if (primaries === "smpte432") {
|
|
return 12;
|
|
}
|
|
throw new Error("Unknown primaries " + primaries);
|
|
};
|
|
var getTransferCharacteristicsValue = (transferCharacteristics) => {
|
|
if (transferCharacteristics === null) {
|
|
return null;
|
|
}
|
|
if (transferCharacteristics === "bt709") {
|
|
return 1;
|
|
}
|
|
if (transferCharacteristics === "smpte170m") {
|
|
return 6;
|
|
}
|
|
if (transferCharacteristics === "iec61966-2-1") {
|
|
return 13;
|
|
}
|
|
if (transferCharacteristics === "linear") {
|
|
return 8;
|
|
}
|
|
if (transferCharacteristics === "pq") {
|
|
return 16;
|
|
}
|
|
if (transferCharacteristics === "hlg") {
|
|
return 18;
|
|
}
|
|
throw new Error("Unknown transfer characteristics " + transferCharacteristics);
|
|
};
|
|
var getMatrixCoefficientsValue = (matrixCoefficients) => {
|
|
if (matrixCoefficients === null) {
|
|
return null;
|
|
}
|
|
if (matrixCoefficients === "rgb") {
|
|
return 0;
|
|
}
|
|
if (matrixCoefficients === "bt709") {
|
|
return 1;
|
|
}
|
|
if (matrixCoefficients === "bt470bg") {
|
|
return 5;
|
|
}
|
|
if (matrixCoefficients === "smpte170m") {
|
|
return 6;
|
|
}
|
|
if (matrixCoefficients === "bt2020-ncl") {
|
|
return 9;
|
|
}
|
|
throw new Error("Unknown matrix coefficients " + matrixCoefficients);
|
|
};
|
|
var makeMatroskaColorBytes = ({
|
|
transfer: transferCharacteristics,
|
|
matrix: matrixCoefficients,
|
|
primaries,
|
|
fullRange
|
|
}) => {
|
|
const rangeValue = getRangeValue({
|
|
transferCharacteristics,
|
|
matrixCoefficients,
|
|
fullRange
|
|
});
|
|
const primariesValue = getPrimariesValue(primaries);
|
|
const transferChracteristicsValue = getTransferCharacteristicsValue(transferCharacteristics);
|
|
if (matrixCoefficients === "rgb") {
|
|
throw new Error("Cannot encode Matroska in RGB");
|
|
}
|
|
const matrixCoefficientsValue = getMatrixCoefficientsValue(matrixCoefficients);
|
|
return makeMatroskaBytes({
|
|
type: "Colour",
|
|
minVintWidth: null,
|
|
value: [
|
|
transferChracteristicsValue === null ? null : {
|
|
type: "TransferCharacteristics",
|
|
value: {
|
|
value: transferChracteristicsValue,
|
|
byteLength: null
|
|
},
|
|
minVintWidth: null
|
|
},
|
|
matrixCoefficientsValue === null ? null : {
|
|
type: "MatrixCoefficients",
|
|
value: {
|
|
value: matrixCoefficientsValue,
|
|
byteLength: null
|
|
},
|
|
minVintWidth: null
|
|
},
|
|
primariesValue === null ? null : {
|
|
type: "Primaries",
|
|
value: {
|
|
value: primariesValue,
|
|
byteLength: null
|
|
},
|
|
minVintWidth: null
|
|
},
|
|
{
|
|
type: "Range",
|
|
value: {
|
|
value: rangeValue,
|
|
byteLength: null
|
|
},
|
|
minVintWidth: null
|
|
}
|
|
].filter(truthy)
|
|
});
|
|
};
|
|
|
|
// src/create/matroska/matroska-trackentry.ts
|
|
var makeMatroskaVideoBytes = ({
|
|
color,
|
|
width,
|
|
height
|
|
}) => {
|
|
return makeMatroskaBytes({
|
|
type: "Video",
|
|
value: [
|
|
{
|
|
type: "PixelWidth",
|
|
value: {
|
|
value: width,
|
|
byteLength: null
|
|
},
|
|
minVintWidth: null
|
|
},
|
|
{
|
|
type: "PixelHeight",
|
|
value: {
|
|
value: height,
|
|
byteLength: null
|
|
},
|
|
minVintWidth: null
|
|
},
|
|
{
|
|
type: "FlagInterlaced",
|
|
value: {
|
|
value: 2,
|
|
byteLength: null
|
|
},
|
|
minVintWidth: null
|
|
},
|
|
makeMatroskaColorBytes(color)
|
|
],
|
|
minVintWidth: null
|
|
});
|
|
};
|
|
var makeVideoCodecId = (codecId) => {
|
|
if (codecId === "vp8") {
|
|
return "V_VP8";
|
|
}
|
|
if (codecId === "vp9") {
|
|
return "V_VP9";
|
|
}
|
|
if (codecId === "h264") {
|
|
return "V_MPEG4/ISO/AVC";
|
|
}
|
|
if (codecId === "av1") {
|
|
return "V_AV1";
|
|
}
|
|
if (codecId === "h265") {
|
|
return "V_MPEGH/ISO/HEVC";
|
|
}
|
|
if (codecId === "prores") {
|
|
return "V_PRORES";
|
|
}
|
|
throw new Error(`Unknown codec: ${codecId}`);
|
|
};
|
|
var makeAudioCodecId = (codecId) => {
|
|
if (codecId === "opus") {
|
|
return "A_OPUS";
|
|
}
|
|
if (codecId === "aac") {
|
|
return "A_AAC";
|
|
}
|
|
if (codecId === "ac3") {
|
|
return "A_AC3";
|
|
}
|
|
if (codecId === "mp3") {
|
|
return "A_MPEG/L3";
|
|
}
|
|
if (codecId === "vorbis") {
|
|
return "A_VORBIS";
|
|
}
|
|
if (codecId === "flac") {
|
|
return "A_FLAC";
|
|
}
|
|
if (codecId === "pcm-u8") {
|
|
return "A_PCM/INT/LIT";
|
|
}
|
|
if (codecId === "pcm-s16") {
|
|
return "A_PCM/INT/LIT";
|
|
}
|
|
if (codecId === "pcm-s24") {
|
|
return "A_PCM/INT/LIT";
|
|
}
|
|
if (codecId === "pcm-s32") {
|
|
return "A_PCM/INT/LIT";
|
|
}
|
|
if (codecId === "pcm-f32") {
|
|
return "A_PCM/INT/LIT";
|
|
}
|
|
if (codecId === "aiff") {
|
|
throw new Error("aiff is not supported in Matroska");
|
|
}
|
|
throw new Error(`Unknown codec: ${codecId}`);
|
|
};
|
|
var makeMatroskaAudioTrackEntryBytes = ({
|
|
trackNumber,
|
|
codec,
|
|
numberOfChannels,
|
|
sampleRate,
|
|
codecPrivate
|
|
}) => {
|
|
return makeMatroskaBytes({
|
|
type: "TrackEntry",
|
|
minVintWidth: null,
|
|
value: [
|
|
{
|
|
type: "TrackNumber",
|
|
value: {
|
|
value: trackNumber,
|
|
byteLength: null
|
|
},
|
|
minVintWidth: null
|
|
},
|
|
{
|
|
type: "TrackType",
|
|
value: {
|
|
value: 2,
|
|
byteLength: null
|
|
},
|
|
minVintWidth: null
|
|
},
|
|
{
|
|
type: "CodecID",
|
|
value: makeAudioCodecId(codec),
|
|
minVintWidth: null
|
|
},
|
|
{
|
|
type: "Audio",
|
|
value: [
|
|
{
|
|
type: "Channels",
|
|
minVintWidth: null,
|
|
value: {
|
|
value: numberOfChannels,
|
|
byteLength: null
|
|
}
|
|
},
|
|
{
|
|
type: "SamplingFrequency",
|
|
minVintWidth: null,
|
|
value: {
|
|
value: sampleRate,
|
|
size: "64"
|
|
}
|
|
},
|
|
{
|
|
type: "BitDepth",
|
|
minVintWidth: null,
|
|
value: {
|
|
value: 32,
|
|
byteLength: null
|
|
}
|
|
}
|
|
],
|
|
minVintWidth: null
|
|
},
|
|
codecPrivate ? {
|
|
type: "CodecPrivate",
|
|
minVintWidth: null,
|
|
value: codecPrivate
|
|
} : null
|
|
].filter(Boolean)
|
|
});
|
|
};
|
|
var makeMatroskaVideoTrackEntryBytes = ({
|
|
color,
|
|
width,
|
|
height,
|
|
trackNumber,
|
|
codec,
|
|
codecPrivate
|
|
}) => {
|
|
return makeMatroskaBytes({
|
|
type: "TrackEntry",
|
|
minVintWidth: null,
|
|
value: [
|
|
{
|
|
type: "TrackNumber",
|
|
value: {
|
|
value: trackNumber,
|
|
byteLength: null
|
|
},
|
|
minVintWidth: null
|
|
},
|
|
{
|
|
type: "Language",
|
|
value: "und",
|
|
minVintWidth: null
|
|
},
|
|
{
|
|
type: "CodecID",
|
|
value: makeVideoCodecId(codec),
|
|
minVintWidth: null
|
|
},
|
|
{
|
|
type: "TrackType",
|
|
value: {
|
|
value: 1,
|
|
byteLength: null
|
|
},
|
|
minVintWidth: null
|
|
},
|
|
makeMatroskaVideoBytes({
|
|
color,
|
|
width,
|
|
height
|
|
}),
|
|
codecPrivate ? {
|
|
type: "CodecPrivate",
|
|
minVintWidth: null,
|
|
value: codecPrivate
|
|
} : null
|
|
].filter(Boolean)
|
|
});
|
|
};
|
|
var makeMatroskaTracks = (tracks) => {
|
|
const bytesArr = tracks.map((t) => {
|
|
const bytes = t.type === "video" ? makeMatroskaVideoTrackEntryBytes(t) : makeMatroskaAudioTrackEntryBytes(t);
|
|
return bytes;
|
|
});
|
|
return padMatroskaBytes(makeMatroskaBytes({
|
|
type: "Tracks",
|
|
value: bytesArr,
|
|
minVintWidth: null
|
|
}), 500);
|
|
};
|
|
|
|
// src/create/matroska/create-matroska-media.ts
|
|
var { matroskaElements } = MediaParserInternals5;
|
|
var timescale = 1e6;
|
|
var createMatroskaMedia = async ({
|
|
writer,
|
|
onBytesProgress,
|
|
onMillisecondsProgress,
|
|
filename,
|
|
logLevel,
|
|
progressTracker
|
|
}) => {
|
|
const header = makeMatroskaHeader();
|
|
const w = await writer.createContent({
|
|
filename,
|
|
mimeType: "video/webm",
|
|
logLevel
|
|
});
|
|
await w.write(header.bytes);
|
|
const matroskaInfo = makeMatroskaInfo({
|
|
timescale
|
|
});
|
|
const currentTracks = [];
|
|
const seeks = [];
|
|
const cues = [];
|
|
const trackNumbers = [];
|
|
const matroskaSegment = createMatroskaSegment([
|
|
...createMatroskaSeekHead(seeks),
|
|
matroskaInfo,
|
|
...makeMatroskaTracks(currentTracks)
|
|
]);
|
|
const infoSegment = matroskaSegment.offsets.children.find((o) => o.field === "Info");
|
|
const durationOffset = (infoSegment?.children.find((c) => c.field === "Duration")?.offset ?? 0) + w.getWrittenByteCount();
|
|
const tracksOffset = (matroskaSegment.offsets.children.find((o) => o.field === "Tracks")?.offset ?? 0) + w.getWrittenByteCount();
|
|
const seekHeadOffset = (matroskaSegment.offsets.children.find((o) => o.field === "SeekHead")?.offset ?? 0) + w.getWrittenByteCount();
|
|
const infoOffset = (infoSegment?.offset ?? 0) + w.getWrittenByteCount();
|
|
if (!seekHeadOffset) {
|
|
throw new Error("could not get seek offset");
|
|
}
|
|
if (!durationOffset) {
|
|
throw new Error("could not get duration offset");
|
|
}
|
|
if (!tracksOffset) {
|
|
throw new Error("could not get tracks offset");
|
|
}
|
|
if (!infoOffset) {
|
|
throw new Error("could not get tracks offset");
|
|
}
|
|
seeks.push({
|
|
hexString: matroskaElements.Info,
|
|
byte: infoOffset - seekHeadOffset
|
|
});
|
|
seeks.push({
|
|
hexString: matroskaElements.Tracks,
|
|
byte: tracksOffset - seekHeadOffset
|
|
});
|
|
const updateSeekWrite = async () => {
|
|
const updatedSeek = createMatroskaSeekHead(seeks);
|
|
await w.updateDataAt(seekHeadOffset, combineUint8Arrays(updatedSeek.map((b) => b.bytes)));
|
|
onBytesProgress(w.getWrittenByteCount());
|
|
};
|
|
const segmentOffset = w.getWrittenByteCount();
|
|
const updateSegmentSize = async (size) => {
|
|
const data = getVariableInt(size, MATROSKA_SEGMENT_MIN_VINT_WIDTH);
|
|
await w.updateDataAt(segmentOffset + matroskaToHex(matroskaElements.Segment).byteLength, data);
|
|
onBytesProgress(w.getWrittenByteCount());
|
|
};
|
|
await w.write(matroskaSegment.bytes);
|
|
const clusterOffset = w.getWrittenByteCount();
|
|
let currentCluster = await makeCluster({
|
|
writer: w,
|
|
clusterStartTimestamp: 0,
|
|
timescale,
|
|
logLevel
|
|
});
|
|
seeks.push({
|
|
hexString: matroskaElements.Cluster,
|
|
byte: clusterOffset - seekHeadOffset
|
|
});
|
|
const getClusterOrMakeNew = async ({
|
|
chunk,
|
|
isVideo
|
|
}) => {
|
|
progressTracker.setPossibleLowestTimestamp(Math.min(chunk.timestamp, chunk.decodingTimestamp ?? Infinity));
|
|
const smallestProgress = progressTracker.getSmallestProgress();
|
|
if (!currentCluster.shouldMakeNewCluster({
|
|
newT: smallestProgress,
|
|
isVideo,
|
|
chunk
|
|
})) {
|
|
return {
|
|
cluster: currentCluster,
|
|
isNew: false,
|
|
smallestProgress
|
|
};
|
|
}
|
|
currentCluster = await makeCluster({
|
|
writer: w,
|
|
clusterStartTimestamp: smallestProgress,
|
|
timescale,
|
|
logLevel
|
|
});
|
|
return {
|
|
cluster: currentCluster,
|
|
isNew: true,
|
|
smallestProgress
|
|
};
|
|
};
|
|
const updateDuration = async (newDuration) => {
|
|
const blocks = makeDurationWithPadding(newDuration);
|
|
await w.updateDataAt(durationOffset, blocks.bytes);
|
|
onBytesProgress(w.getWrittenByteCount());
|
|
};
|
|
const addSample = async ({
|
|
chunk,
|
|
trackNumber,
|
|
isVideo
|
|
}) => {
|
|
const offset = w.getWrittenByteCount();
|
|
const { cluster, isNew, smallestProgress } = await getClusterOrMakeNew({
|
|
chunk,
|
|
isVideo
|
|
});
|
|
const newDuration = Math.round((chunk.timestamp + (chunk.duration ?? 0)) / 1000);
|
|
await updateDuration(newDuration);
|
|
const { timecodeRelativeToCluster } = await cluster.addSample(chunk, trackNumber);
|
|
if (isNew) {
|
|
if (offset === null) {
|
|
throw new Error("offset is null");
|
|
}
|
|
cues.push({
|
|
time: timestampToClusterTimestamp(smallestProgress, timescale) + timecodeRelativeToCluster,
|
|
clusterPosition: offset - seekHeadOffset,
|
|
trackNumber
|
|
});
|
|
}
|
|
if (chunk.type === "key") {
|
|
progressTracker.updateTrackProgress(trackNumber, chunk.timestamp);
|
|
}
|
|
onBytesProgress(w.getWrittenByteCount());
|
|
onMillisecondsProgress(newDuration);
|
|
};
|
|
const addTrack = async (track) => {
|
|
currentTracks.push(track);
|
|
const newTracks = makeMatroskaTracks(currentTracks);
|
|
progressTracker.registerTrack(track.trackNumber);
|
|
await w.updateDataAt(tracksOffset, combineUint8Arrays(newTracks.map((b) => b.bytes)));
|
|
};
|
|
const operationProm = { current: Promise.resolve() };
|
|
const waitForFinishPromises = [];
|
|
return {
|
|
updateTrackSampleRate: ({ sampleRate, trackNumber }) => {
|
|
currentTracks.forEach((track) => {
|
|
if (track.trackNumber === trackNumber) {
|
|
if (track.type !== "audio") {
|
|
throw new Error("track is not audio");
|
|
}
|
|
track.sampleRate = sampleRate;
|
|
}
|
|
});
|
|
},
|
|
getBlob: () => {
|
|
return w.getBlob();
|
|
},
|
|
remove: async () => {
|
|
await w.remove();
|
|
},
|
|
addSample: ({ chunk, trackNumber, isVideo }) => {
|
|
operationProm.current = operationProm.current.then(() => addSample({ chunk, trackNumber, isVideo }));
|
|
return operationProm.current;
|
|
},
|
|
addTrack: (track) => {
|
|
const trackNumber = currentTracks.length + 1;
|
|
operationProm.current = operationProm.current.then(() => addTrack({ ...track, trackNumber }));
|
|
trackNumbers.push(trackNumber);
|
|
return operationProm.current.then(() => ({ trackNumber }));
|
|
},
|
|
addWaitForFinishPromise: (promise) => {
|
|
waitForFinishPromises.push(promise);
|
|
},
|
|
async waitForFinish() {
|
|
await Promise.all(waitForFinishPromises.map((p) => p()));
|
|
await operationProm.current;
|
|
const cuesBytes = createMatroskaCues(cues);
|
|
if (cuesBytes) {
|
|
seeks.push({
|
|
hexString: matroskaElements.Cues,
|
|
byte: w.getWrittenByteCount() - seekHeadOffset
|
|
});
|
|
await w.write(cuesBytes.bytes);
|
|
}
|
|
await updateSeekWrite();
|
|
const segmentSize = w.getWrittenByteCount() - segmentOffset - matroskaToHex(matroskaElements.Segment).byteLength - MATROSKA_SEGMENT_MIN_VINT_WIDTH;
|
|
await updateSegmentSize(segmentSize);
|
|
await w.finish();
|
|
}
|
|
};
|
|
};
|
|
|
|
// src/create/wav/create-wav.ts
|
|
var numberTo32BiIntLittleEndian = (num) => {
|
|
return new Uint8Array([
|
|
num & 255,
|
|
num >> 8 & 255,
|
|
num >> 16 & 255,
|
|
num >> 24 & 255
|
|
]);
|
|
};
|
|
var numberTo16BitLittleEndian = (num) => {
|
|
return new Uint8Array([num & 255, num >> 8 & 255]);
|
|
};
|
|
var BIT_DEPTH = 16;
|
|
var BYTES_PER_SAMPLE = BIT_DEPTH / 8;
|
|
var createWav = async ({
|
|
filename,
|
|
logLevel,
|
|
onBytesProgress,
|
|
onMillisecondsProgress,
|
|
writer,
|
|
progressTracker
|
|
}) => {
|
|
const w = await writer.createContent({
|
|
filename,
|
|
mimeType: "audio/wav",
|
|
logLevel
|
|
});
|
|
await w.write(new Uint8Array([82, 73, 70, 70]));
|
|
const sizePosition = w.getWrittenByteCount();
|
|
await w.write(new Uint8Array([0, 0, 0, 0]));
|
|
await w.write(new Uint8Array([87, 65, 86, 69]));
|
|
await w.write(new Uint8Array([102, 109, 116, 32]));
|
|
await w.write(new Uint8Array([16, 0, 0, 0]));
|
|
await w.write(new Uint8Array([1, 0]));
|
|
const channelNumPosition = w.getWrittenByteCount();
|
|
await w.write(new Uint8Array([1, 0]));
|
|
const sampleRatePosition = w.getWrittenByteCount();
|
|
await w.write(new Uint8Array([0, 0, 0, 0]));
|
|
const byteRatePosition = w.getWrittenByteCount();
|
|
await w.write(new Uint8Array([0, 0, 0, 0]));
|
|
const blockAlignPosition = w.getWrittenByteCount();
|
|
await w.write(new Uint8Array([0, 0]));
|
|
await w.write(numberTo16BitLittleEndian(BIT_DEPTH));
|
|
await w.write(new Uint8Array([100, 97, 116, 97]));
|
|
const dataSizePosition = w.getWrittenByteCount();
|
|
await w.write(new Uint8Array([0, 0, 0, 0]));
|
|
const operationProm = { current: Promise.resolve() };
|
|
const updateSize = async () => {
|
|
const size = w.getWrittenByteCount() - sizePosition - 4;
|
|
await w.updateDataAt(sizePosition, numberTo32BiIntLittleEndian(size));
|
|
const dataSize = w.getWrittenByteCount() - dataSizePosition - 4;
|
|
await w.updateDataAt(dataSizePosition, numberTo32BiIntLittleEndian(dataSize));
|
|
};
|
|
const updateChannelNum = async (numberOfChannels) => {
|
|
await w.updateDataAt(channelNumPosition, new Uint8Array([numberOfChannels, 0]));
|
|
};
|
|
const updateSampleRate = async (sampleRate) => {
|
|
await w.updateDataAt(sampleRatePosition, numberTo32BiIntLittleEndian(sampleRate));
|
|
};
|
|
const updateByteRate = async ({
|
|
sampleRate,
|
|
numberOfChannels
|
|
}) => {
|
|
await w.updateDataAt(byteRatePosition, numberTo32BiIntLittleEndian(sampleRate * numberOfChannels + BYTES_PER_SAMPLE));
|
|
};
|
|
const updateBlockAlign = async (numberOfChannels) => {
|
|
await w.updateDataAt(blockAlignPosition, new Uint8Array(numberTo16BitLittleEndian(numberOfChannels * BYTES_PER_SAMPLE)));
|
|
};
|
|
const addSample = async (chunk) => {
|
|
Log.trace(logLevel, "Adding sample", chunk);
|
|
await w.write(chunk.data);
|
|
onMillisecondsProgress((chunk.timestamp + (chunk.duration ?? 0)) / 1000);
|
|
onBytesProgress(w.getWrittenByteCount());
|
|
};
|
|
const waitForFinishPromises = [];
|
|
return {
|
|
getBlob: () => {
|
|
return w.getBlob();
|
|
},
|
|
remove: () => {
|
|
return w.remove();
|
|
},
|
|
addSample: ({ chunk, trackNumber }) => {
|
|
if (trackNumber !== 1) {
|
|
throw new Error("Only one track supported for WAV");
|
|
}
|
|
operationProm.current = operationProm.current.then(() => addSample(chunk));
|
|
progressTracker.updateTrackProgress(trackNumber, chunk.timestamp);
|
|
return operationProm.current;
|
|
},
|
|
updateTrackSampleRate: () => {
|
|
throw new Error("updateTrackSampleRate() not implemented for WAV encoder");
|
|
},
|
|
addWaitForFinishPromise: (promise) => {
|
|
waitForFinishPromises.push(promise);
|
|
},
|
|
async waitForFinish() {
|
|
Log.verbose(logLevel, "All write operations queued. Waiting for finish...");
|
|
await Promise.all(waitForFinishPromises.map((p) => p()));
|
|
await operationProm.current;
|
|
await updateSize();
|
|
await w.finish();
|
|
},
|
|
addTrack: async (track) => {
|
|
if (track.type !== "audio") {
|
|
throw new Error("Only audio tracks supported for WAV");
|
|
}
|
|
await updateChannelNum(track.numberOfChannels);
|
|
await updateSampleRate(track.sampleRate);
|
|
await updateByteRate({
|
|
sampleRate: track.sampleRate,
|
|
numberOfChannels: track.numberOfChannels
|
|
});
|
|
await updateBlockAlign(track.numberOfChannels);
|
|
progressTracker.registerTrack(1);
|
|
return Promise.resolve({ trackNumber: 1 });
|
|
}
|
|
};
|
|
};
|
|
|
|
// src/create-media.ts
|
|
var createMedia = (params) => {
|
|
if (params.container === "mp4") {
|
|
return createIsoBaseMedia(params);
|
|
}
|
|
if (params.container === "wav") {
|
|
return createWav(params);
|
|
}
|
|
if (params.container === "webm") {
|
|
return createMatroskaMedia(params);
|
|
}
|
|
throw new Error(`Unsupported container: ${params.container}`);
|
|
};
|
|
|
|
// src/create/progress-tracker.ts
|
|
var makeProgressTracker = () => {
|
|
const trackNumberProgresses = {};
|
|
const eventEmitter = new IoEventEmitter;
|
|
let startingTimestamp = null;
|
|
const setPossibleLowestTimestamp = (timestamp) => {
|
|
if (startingTimestamp === null) {
|
|
startingTimestamp = timestamp;
|
|
} else {
|
|
startingTimestamp = Math.min(startingTimestamp, timestamp);
|
|
}
|
|
};
|
|
const getSmallestProgress = () => {
|
|
const progressValues = Object.values(trackNumberProgresses).map((p) => {
|
|
if (p !== null) {
|
|
return p;
|
|
}
|
|
if (startingTimestamp === null) {
|
|
throw new Error("No progress values to calculate smallest progress from");
|
|
}
|
|
return startingTimestamp;
|
|
});
|
|
return Math.min(...progressValues);
|
|
};
|
|
return {
|
|
registerTrack: (trackNumber) => {
|
|
trackNumberProgresses[trackNumber] = null;
|
|
},
|
|
getSmallestProgress,
|
|
updateTrackProgress: (trackNumber, progress) => {
|
|
if (trackNumberProgresses[trackNumber] === undefined) {
|
|
throw new Error(`Tried to update progress for a track that was not registered: ${trackNumber}`);
|
|
}
|
|
trackNumberProgresses[trackNumber] = progress;
|
|
eventEmitter.dispatchEvent("progress", {
|
|
smallestProgress: getSmallestProgress()
|
|
});
|
|
},
|
|
setPossibleLowestTimestamp
|
|
};
|
|
};
|
|
|
|
// src/generate-output-filename.ts
|
|
var generateOutputFilename = (source, container) => {
|
|
const filename = typeof source === "string" ? source : source instanceof File ? source.name : "converted";
|
|
const behindSlash = filename.split("/").pop();
|
|
const withoutExtension = behindSlash.split(".").slice(0, -1).join(".");
|
|
return `${withoutExtension}.${container}`;
|
|
};
|
|
|
|
// src/get-available-containers.ts
|
|
var availableContainers = ["webm", "mp4", "wav"];
|
|
var getAvailableContainers = () => {
|
|
return availableContainers;
|
|
};
|
|
|
|
// src/get-available-video-codecs.ts
|
|
var availableVideoCodecs = ["vp8", "vp9", "h264", "h265"];
|
|
var getAvailableVideoCodecs = ({
|
|
container
|
|
}) => {
|
|
if (container === "mp4") {
|
|
return ["h264", "h265"];
|
|
}
|
|
if (container === "webm") {
|
|
return ["vp8", "vp9"];
|
|
}
|
|
if (container === "wav") {
|
|
return [];
|
|
}
|
|
throw new Error(`Unsupported container: ${container}`);
|
|
};
|
|
|
|
// src/copy-audio-track.ts
|
|
var copyAudioTrack = async ({
|
|
state,
|
|
track,
|
|
logLevel,
|
|
onMediaStateUpdate,
|
|
progressTracker
|
|
}) => {
|
|
const addedTrack = await state.addTrack({
|
|
type: "audio",
|
|
codec: track.codecEnum,
|
|
numberOfChannels: track.numberOfChannels,
|
|
sampleRate: track.sampleRate,
|
|
codecPrivate: track.codecData?.data ?? null,
|
|
timescale: track.originalTimescale
|
|
});
|
|
Log.verbose(logLevel, `Copying audio track ${track.trackId} as track ${addedTrack.trackNumber}. Timescale = ${track.originalTimescale}, codec = ${track.codecEnum} (${track.codec}) `);
|
|
return async (audioSample) => {
|
|
progressTracker.setPossibleLowestTimestamp(Math.min(audioSample.timestamp, audioSample.decodingTimestamp ?? Infinity));
|
|
await state.addSample({
|
|
chunk: audioSample,
|
|
trackNumber: addedTrack.trackNumber,
|
|
isVideo: false,
|
|
codecPrivate: track.codecData?.data ?? null
|
|
});
|
|
onMediaStateUpdate?.((prevState) => {
|
|
return {
|
|
...prevState,
|
|
encodedAudioFrames: prevState.encodedAudioFrames + 1
|
|
};
|
|
});
|
|
};
|
|
};
|
|
|
|
// src/default-on-audio-track-handler.ts
|
|
import { MediaParserInternals as MediaParserInternals6 } from "@remotion/media-parser";
|
|
var DEFAULT_BITRATE = 128000;
|
|
var defaultOnAudioTrackHandler = async ({
|
|
track,
|
|
defaultAudioCodec,
|
|
logLevel,
|
|
canCopyTrack
|
|
}) => {
|
|
const bitrate = DEFAULT_BITRATE;
|
|
if (canCopyTrack) {
|
|
MediaParserInternals6.Log.verbose(logLevel, `Track ${track.trackId} (audio): Can copy track, therefore copying`);
|
|
return Promise.resolve({ type: "copy" });
|
|
}
|
|
if (defaultAudioCodec === null) {
|
|
MediaParserInternals6.Log.verbose(logLevel, `Track ${track.trackId} (audio): Container does not support audio, dropping audio`);
|
|
return Promise.resolve({ type: "drop" });
|
|
}
|
|
const canReencode = await canReencodeAudioTrack({
|
|
audioCodec: defaultAudioCodec,
|
|
track,
|
|
bitrate,
|
|
sampleRate: null
|
|
});
|
|
if (canReencode) {
|
|
MediaParserInternals6.Log.verbose(logLevel, `Track ${track.trackId} (audio): Cannot copy, but re-encode, therefore re-encoding`);
|
|
return Promise.resolve({
|
|
type: "reencode",
|
|
bitrate,
|
|
audioCodec: defaultAudioCodec,
|
|
sampleRate: null
|
|
});
|
|
}
|
|
MediaParserInternals6.Log.verbose(logLevel, `Track ${track.trackId} (audio): Can neither re-encode nor copy, failing render`);
|
|
return Promise.resolve({ type: "fail" });
|
|
};
|
|
|
|
// src/get-default-audio-codec.ts
|
|
var getDefaultAudioCodec = ({
|
|
container
|
|
}) => {
|
|
if (container === "webm") {
|
|
return "opus";
|
|
}
|
|
if (container === "mp4") {
|
|
return "aac";
|
|
}
|
|
if (container === "wav") {
|
|
return "wav";
|
|
}
|
|
throw new Error(`Unhandled container: ${container}`);
|
|
};
|
|
|
|
// src/reencode-audio-track.ts
|
|
import { MediaParserInternals as MediaParserInternals7 } from "@remotion/media-parser";
|
|
|
|
// src/convert-encoded-chunk.ts
|
|
var convertEncodedChunk = (chunk) => {
|
|
const arr = new Uint8Array(chunk.byteLength);
|
|
chunk.copyTo(arr);
|
|
return {
|
|
data: arr,
|
|
duration: chunk.duration ?? undefined,
|
|
timestamp: chunk.timestamp,
|
|
type: chunk.type,
|
|
decodingTimestamp: chunk.timestamp,
|
|
offset: 0
|
|
};
|
|
};
|
|
|
|
// src/flush-pending.ts
|
|
var makeFlushPending = () => {
|
|
const { promise, resolve, reject } = withResolvers();
|
|
return {
|
|
promise,
|
|
resolve,
|
|
reject
|
|
};
|
|
};
|
|
|
|
// src/get-wave-audio-decoder.ts
|
|
var getBytesPerSample = (sampleFormat) => {
|
|
if (sampleFormat === "s16") {
|
|
return 2;
|
|
}
|
|
if (sampleFormat === "s24") {
|
|
return 4;
|
|
}
|
|
throw new Error(`Unsupported sample format: ${sampleFormat}`);
|
|
};
|
|
function uint8_24le_to_uint32(u8) {
|
|
if (u8.length % 3 !== 0) {
|
|
throw new Error("Input length must be a multiple of 3");
|
|
}
|
|
const count = u8.length / 3;
|
|
const out = new Uint32Array(count);
|
|
let j = 0;
|
|
for (let i = 0;i < count; i++) {
|
|
const b0 = u8[j++];
|
|
const b1 = u8[j++];
|
|
const b2 = u8[j++];
|
|
out[i] = (b0 | b1 << 8 | b2 << 16) << 8;
|
|
}
|
|
return out;
|
|
}
|
|
var getAudioData = (audioSample) => {
|
|
if (audioSample instanceof EncodedAudioChunk) {
|
|
const data = new Uint8Array(audioSample.byteLength);
|
|
audioSample.copyTo(data);
|
|
return data;
|
|
}
|
|
return audioSample.data;
|
|
};
|
|
var getWaveAudioDecoder = ({
|
|
onFrame,
|
|
config,
|
|
sampleFormat,
|
|
ioSynchronizer,
|
|
onError
|
|
}) => {
|
|
const processSample = async (audioSample) => {
|
|
const bytesPerSample = getBytesPerSample(sampleFormat);
|
|
const rawData = getAudioData(audioSample);
|
|
const data = sampleFormat === "s24" && rawData instanceof Uint8Array ? uint8_24le_to_uint32(rawData) : rawData;
|
|
const numberOfFrames = data.byteLength / bytesPerSample / config.numberOfChannels;
|
|
const audioData = new AudioData({
|
|
data,
|
|
format: sampleFormat === "s16" ? "s16" : "s32",
|
|
numberOfChannels: config.numberOfChannels,
|
|
numberOfFrames,
|
|
sampleRate: config.sampleRate,
|
|
timestamp: audioSample.timestamp
|
|
});
|
|
try {
|
|
await onFrame(audioData);
|
|
} catch (err) {
|
|
audioData.close();
|
|
onError(err);
|
|
}
|
|
};
|
|
let lastReset = null;
|
|
let mostRecentSampleInput = null;
|
|
return {
|
|
close() {
|
|
return Promise.resolve();
|
|
},
|
|
decode(audioSample) {
|
|
mostRecentSampleInput = audioSample.timestamp;
|
|
return processSample(audioSample);
|
|
},
|
|
flush: () => Promise.resolve(),
|
|
waitForQueueToBeLessThan: ioSynchronizer.waitForQueueSize,
|
|
reset: () => {
|
|
lastReset = Date.now();
|
|
},
|
|
checkReset: () => ({
|
|
wasReset: () => lastReset !== null && lastReset > Date.now()
|
|
}),
|
|
getMostRecentSampleInput: () => mostRecentSampleInput
|
|
};
|
|
};
|
|
|
|
// src/undecodable-error.ts
|
|
class VideoUndecodableError extends Error {
|
|
config;
|
|
constructor({
|
|
message,
|
|
config
|
|
}) {
|
|
super(message);
|
|
this.name = "VideoUndecodableError";
|
|
this.config = config;
|
|
if (Error.captureStackTrace) {
|
|
Error.captureStackTrace(this, VideoUndecodableError);
|
|
}
|
|
}
|
|
}
|
|
|
|
class AudioUndecodableError extends Error {
|
|
config;
|
|
constructor({
|
|
message,
|
|
config
|
|
}) {
|
|
super(message);
|
|
this.name = "AudioUndecodableError";
|
|
this.config = config;
|
|
if (Error.captureStackTrace) {
|
|
Error.captureStackTrace(this, AudioUndecodableError);
|
|
}
|
|
}
|
|
}
|
|
|
|
// src/create-audio-decoder.ts
|
|
var internalCreateAudioDecoder = async ({
|
|
onFrame,
|
|
onError,
|
|
controller,
|
|
config,
|
|
logLevel
|
|
}) => {
|
|
if (controller && controller._internals._mediaParserController._internals.signal.aborted) {
|
|
throw new Error("Not creating audio decoder, already aborted");
|
|
}
|
|
const ioSynchronizer = makeIoSynchronizer({
|
|
logLevel,
|
|
label: "Audio decoder",
|
|
controller
|
|
});
|
|
let mostRecentSampleReceived = null;
|
|
if (config.codec === "pcm-s16") {
|
|
return getWaveAudioDecoder({
|
|
onFrame,
|
|
config,
|
|
sampleFormat: "s16",
|
|
logLevel,
|
|
ioSynchronizer,
|
|
onError
|
|
});
|
|
}
|
|
if (config.codec === "pcm-s24") {
|
|
return getWaveAudioDecoder({
|
|
onFrame,
|
|
config,
|
|
sampleFormat: "s24",
|
|
logLevel,
|
|
ioSynchronizer,
|
|
onError
|
|
});
|
|
}
|
|
const audioDecoder = new AudioDecoder({
|
|
async output(frame) {
|
|
try {
|
|
await onFrame(frame);
|
|
} catch (err) {
|
|
frame.close();
|
|
onError(err);
|
|
}
|
|
ioSynchronizer.onOutput(frame.timestamp + (frame.duration ?? 0));
|
|
},
|
|
error(error) {
|
|
onError(error);
|
|
}
|
|
});
|
|
const close = () => {
|
|
if (controller) {
|
|
controller._internals._mediaParserController._internals.signal.removeEventListener("abort", onAbort);
|
|
}
|
|
if (audioDecoder.state === "closed") {
|
|
return;
|
|
}
|
|
audioDecoder.close();
|
|
};
|
|
const onAbort = () => {
|
|
close();
|
|
};
|
|
if (controller) {
|
|
controller._internals._mediaParserController._internals.signal.addEventListener("abort", onAbort);
|
|
}
|
|
const isConfigSupported = await AudioDecoder.isConfigSupported(config);
|
|
if (!isConfigSupported) {
|
|
throw new AudioUndecodableError({
|
|
message: "Audio cannot be decoded by this browser",
|
|
config
|
|
});
|
|
}
|
|
audioDecoder.configure(config);
|
|
const decode = async (audioSample) => {
|
|
if (audioDecoder.state === "closed") {
|
|
return;
|
|
}
|
|
try {
|
|
await controller?._internals._mediaParserController._internals.checkForAbortAndPause();
|
|
} catch (err) {
|
|
onError(err);
|
|
return;
|
|
}
|
|
mostRecentSampleReceived = audioSample.timestamp;
|
|
const chunk = audioSample instanceof EncodedAudioChunk ? audioSample : new EncodedAudioChunk(audioSample);
|
|
audioDecoder.decode(chunk);
|
|
if (chunk.byteLength > 16) {
|
|
ioSynchronizer.inputItem(chunk.timestamp);
|
|
}
|
|
};
|
|
let flushPending = null;
|
|
const lastReset = null;
|
|
return {
|
|
decode,
|
|
close,
|
|
flush: () => {
|
|
if (flushPending) {
|
|
throw new Error("Flush already pending");
|
|
}
|
|
const pendingFlush = makeFlushPending();
|
|
flushPending = pendingFlush;
|
|
Promise.resolve().then(() => {
|
|
return audioDecoder.flush();
|
|
}).catch(() => {}).finally(() => {
|
|
pendingFlush.resolve();
|
|
flushPending = null;
|
|
});
|
|
return pendingFlush.promise;
|
|
},
|
|
waitForQueueToBeLessThan: ioSynchronizer.waitForQueueSize,
|
|
reset: () => {
|
|
audioDecoder.reset();
|
|
audioDecoder.configure(config);
|
|
},
|
|
checkReset: () => {
|
|
const initTime = Date.now();
|
|
return {
|
|
wasReset: () => lastReset !== null && lastReset > initTime
|
|
};
|
|
},
|
|
getMostRecentSampleInput() {
|
|
return mostRecentSampleReceived;
|
|
}
|
|
};
|
|
};
|
|
var createAudioDecoder = ({
|
|
track,
|
|
onFrame,
|
|
onError,
|
|
controller,
|
|
logLevel
|
|
}) => {
|
|
return internalCreateAudioDecoder({
|
|
onFrame,
|
|
onError,
|
|
controller: controller ?? null,
|
|
config: track,
|
|
logLevel: logLevel ?? "error"
|
|
});
|
|
};
|
|
|
|
// src/processing-queue.ts
|
|
function processingQueue({
|
|
onOutput,
|
|
logLevel,
|
|
label,
|
|
onError,
|
|
controller
|
|
}) {
|
|
const ioSynchronizer = makeIoSynchronizer({
|
|
logLevel,
|
|
label,
|
|
controller
|
|
});
|
|
let queue = Promise.resolve();
|
|
let stopped = false;
|
|
const input = (item) => {
|
|
if (stopped) {
|
|
return;
|
|
}
|
|
if (controller._internals._mediaParserController._internals.signal.aborted) {
|
|
stopped = true;
|
|
return;
|
|
}
|
|
const { timestamp } = item;
|
|
ioSynchronizer.inputItem(timestamp);
|
|
queue = queue.then(() => {
|
|
if (stopped) {
|
|
return;
|
|
}
|
|
if (controller._internals._mediaParserController._internals.signal.aborted) {
|
|
stopped = true;
|
|
return;
|
|
}
|
|
return onOutput(item);
|
|
}).then(() => {
|
|
ioSynchronizer.onOutput(timestamp);
|
|
return Promise.resolve();
|
|
}).catch((err) => {
|
|
stopped = true;
|
|
onError(err);
|
|
});
|
|
};
|
|
return {
|
|
input,
|
|
ioSynchronizer
|
|
};
|
|
}
|
|
|
|
// src/reencode-audio-track.ts
|
|
var reencodeAudioTrack = async ({
|
|
audioOperation,
|
|
track,
|
|
logLevel,
|
|
abortConversion,
|
|
state,
|
|
controller,
|
|
onMediaStateUpdate,
|
|
onAudioData,
|
|
progressTracker
|
|
}) => {
|
|
if (audioOperation.type !== "reencode") {
|
|
throw new Error(`Audio track with ID ${track.trackId} could not be resolved with a valid operation. Received ${JSON.stringify(audioOperation)}, but must be either "copy", "reencode", "drop" or "fail"`);
|
|
}
|
|
const audioEncoderConfig = await getAudioEncoderConfig({
|
|
numberOfChannels: track.numberOfChannels,
|
|
sampleRate: audioOperation.sampleRate ?? track.sampleRate,
|
|
codec: audioOperation.audioCodec,
|
|
bitrate: audioOperation.bitrate
|
|
});
|
|
const audioDecoderConfig = await getAudioDecoderConfig({
|
|
codec: track.codec,
|
|
numberOfChannels: track.numberOfChannels,
|
|
sampleRate: track.sampleRate,
|
|
description: track.description
|
|
});
|
|
Log.verbose(logLevel, "Audio encoder config", audioEncoderConfig);
|
|
Log.verbose(logLevel, "Audio decoder config", audioDecoderConfig ?? track);
|
|
if (!audioEncoderConfig) {
|
|
abortConversion(new Error(`Could not configure audio encoder of track ${track.trackId}`));
|
|
return null;
|
|
}
|
|
if (!audioDecoderConfig) {
|
|
abortConversion(new Error(`Could not configure audio decoder of track ${track.trackId}`));
|
|
return null;
|
|
}
|
|
const codecPrivate = audioOperation.audioCodec === "aac" ? MediaParserInternals7.createAacCodecPrivate({
|
|
audioObjectType: 2,
|
|
sampleRate: audioOperation.sampleRate ?? audioEncoderConfig.sampleRate,
|
|
channelConfiguration: audioEncoderConfig.numberOfChannels,
|
|
codecPrivate: null
|
|
}) : null;
|
|
const { trackNumber } = await state.addTrack({
|
|
type: "audio",
|
|
codec: audioOperation.audioCodec === "wav" ? "pcm-s16" : audioOperation.audioCodec,
|
|
numberOfChannels: audioEncoderConfig.numberOfChannels,
|
|
sampleRate: audioOperation.sampleRate ?? audioEncoderConfig.sampleRate,
|
|
codecPrivate,
|
|
timescale: track.originalTimescale
|
|
});
|
|
const audioEncoder = createAudioEncoder({
|
|
onNewAudioSampleRate: (sampleRate) => {
|
|
state.updateTrackSampleRate({ sampleRate, trackNumber });
|
|
},
|
|
onChunk: async (chunk) => {
|
|
await state.addSample({
|
|
chunk: convertEncodedChunk(chunk),
|
|
trackNumber,
|
|
isVideo: false,
|
|
codecPrivate
|
|
});
|
|
onMediaStateUpdate?.((prevState) => {
|
|
return {
|
|
...prevState,
|
|
encodedAudioFrames: prevState.encodedAudioFrames + 1
|
|
};
|
|
});
|
|
},
|
|
onError: (err) => {
|
|
abortConversion(new Error(`Audio encoder of track ${track.trackId} failed (see .cause of this error)`, {
|
|
cause: err
|
|
}));
|
|
},
|
|
codec: audioOperation.audioCodec,
|
|
controller,
|
|
config: audioEncoderConfig,
|
|
logLevel
|
|
});
|
|
const audioProcessingQueue = processingQueue({
|
|
controller,
|
|
label: "AudioData processing queue",
|
|
logLevel,
|
|
onError(error) {
|
|
abortConversion(new Error(`Audio decoder of track ${track.trackId} failed. Config: ${JSON.stringify(audioDecoderConfig)} (see .cause of this error)`, {
|
|
cause: error
|
|
}));
|
|
},
|
|
onOutput: async (audioData) => {
|
|
const newAudioData = onAudioData ? await onAudioData?.({ audioData, track }) : audioData;
|
|
if (newAudioData !== audioData) {
|
|
if (newAudioData.duration !== audioData.duration) {
|
|
throw new Error(`onAudioData returned a different duration than the input audio data. Original duration: ${audioData.duration}, new duration: ${newAudioData.duration}`);
|
|
}
|
|
if (newAudioData.numberOfChannels !== audioData.numberOfChannels) {
|
|
throw new Error(`onAudioData returned a different number of channels than the input audio data. Original channels: ${audioData.numberOfChannels}, new channels: ${newAudioData.numberOfChannels}`);
|
|
}
|
|
if (newAudioData.sampleRate !== audioData.sampleRate) {
|
|
throw new Error(`onAudioData returned a different sample rate than the input audio data. Original sample rate: ${audioData.sampleRate}, new sample rate: ${newAudioData.sampleRate}`);
|
|
}
|
|
if (newAudioData.format !== audioData.format) {
|
|
throw new Error(`onAudioData returned a different format than the input audio data. Original format: ${audioData.format}, new format: ${newAudioData.format}`);
|
|
}
|
|
if (newAudioData.timestamp !== audioData.timestamp) {
|
|
throw new Error(`onAudioData returned a different timestamp than the input audio data. Original timestamp: ${audioData.timestamp}, new timestamp: ${newAudioData.timestamp}`);
|
|
}
|
|
audioData.close();
|
|
}
|
|
await controller._internals._mediaParserController._internals.checkForAbortAndPause();
|
|
await audioEncoder.ioSynchronizer.waitForQueueSize(10);
|
|
await controller._internals._mediaParserController._internals.checkForAbortAndPause();
|
|
audioEncoder.encode(newAudioData);
|
|
onMediaStateUpdate?.((prevState) => {
|
|
return {
|
|
...prevState,
|
|
decodedAudioFrames: prevState.decodedAudioFrames + 1
|
|
};
|
|
});
|
|
newAudioData.close();
|
|
}
|
|
});
|
|
const audioDecoder = await internalCreateAudioDecoder({
|
|
onFrame: async (audioData) => {
|
|
await controller._internals._mediaParserController._internals.checkForAbortAndPause();
|
|
await audioProcessingQueue.ioSynchronizer.waitForQueueSize(10);
|
|
audioProcessingQueue.input(audioData);
|
|
},
|
|
onError(error) {
|
|
abortConversion(new Error(`Audio decoder of track ${track.trackId} failed. Config: ${JSON.stringify(audioDecoderConfig)} (see .cause of this error)`, {
|
|
cause: error
|
|
}));
|
|
},
|
|
controller,
|
|
config: audioDecoderConfig,
|
|
logLevel
|
|
});
|
|
state.addWaitForFinishPromise(async () => {
|
|
Log.verbose(logLevel, "Waiting for audio decoder to finish");
|
|
await audioDecoder.flush();
|
|
Log.verbose(logLevel, "Audio decoder finished");
|
|
audioDecoder.close();
|
|
await audioProcessingQueue.ioSynchronizer.waitForQueueSize(0);
|
|
Log.verbose(logLevel, "Audio processing queue finished");
|
|
await audioEncoder.waitForFinish();
|
|
Log.verbose(logLevel, "Audio encoder finished");
|
|
audioEncoder.close();
|
|
});
|
|
return async (audioSample) => {
|
|
progressTracker.setPossibleLowestTimestamp(Math.min(audioSample.timestamp, audioSample.decodingTimestamp ?? Infinity));
|
|
await controller._internals._mediaParserController._internals.checkForAbortAndPause();
|
|
await audioDecoder.waitForQueueToBeLessThan(10);
|
|
audioDecoder.decode(audioSample);
|
|
};
|
|
};
|
|
|
|
// src/on-audio-track.ts
|
|
var makeAudioTrackHandler = ({
|
|
state,
|
|
defaultAudioCodec: audioCodec,
|
|
controller,
|
|
abortConversion,
|
|
onMediaStateUpdate,
|
|
onAudioTrack,
|
|
logLevel,
|
|
outputContainer,
|
|
onAudioData,
|
|
progressTracker
|
|
}) => async ({ track, container: inputContainer }) => {
|
|
const canCopyTrack = canCopyAudioTrack({
|
|
inputCodec: track.codecEnum,
|
|
outputContainer,
|
|
inputContainer,
|
|
outputAudioCodec: audioCodec
|
|
});
|
|
const audioOperation = await (onAudioTrack ?? defaultOnAudioTrackHandler)({
|
|
defaultAudioCodec: audioCodec ?? getDefaultAudioCodec({ container: outputContainer }),
|
|
track,
|
|
logLevel,
|
|
outputContainer,
|
|
inputContainer,
|
|
canCopyTrack
|
|
});
|
|
if (audioOperation.type === "drop") {
|
|
return null;
|
|
}
|
|
if (audioOperation.type === "fail") {
|
|
throw new Error(`Audio track with ID ${track.trackId} resolved with {"type": "fail"}. This could mean that this audio track could neither be copied to the output container or re-encoded. You have the option to drop the track instead of failing it: https://remotion.dev/docs/webcodecs/track-transformation`);
|
|
}
|
|
if (audioOperation.type === "copy") {
|
|
return copyAudioTrack({
|
|
logLevel,
|
|
onMediaStateUpdate,
|
|
state,
|
|
track,
|
|
progressTracker
|
|
});
|
|
}
|
|
return reencodeAudioTrack({
|
|
abortConversion,
|
|
controller,
|
|
logLevel,
|
|
onMediaStateUpdate,
|
|
audioOperation,
|
|
onAudioData,
|
|
state,
|
|
track,
|
|
progressTracker
|
|
});
|
|
};
|
|
|
|
// src/copy-video-track.ts
|
|
var copyVideoTrack = async ({
|
|
logLevel,
|
|
state,
|
|
track,
|
|
onMediaStateUpdate,
|
|
progressTracker
|
|
}) => {
|
|
Log.verbose(logLevel, `Copying video track with codec ${track.codec} and timescale ${track.originalTimescale}`);
|
|
const videoTrack = await state.addTrack({
|
|
type: "video",
|
|
color: track.advancedColor,
|
|
width: track.codedWidth,
|
|
height: track.codedHeight,
|
|
codec: track.codecEnum,
|
|
codecPrivate: track.codecData?.data ?? null,
|
|
timescale: track.originalTimescale
|
|
});
|
|
return async (sample) => {
|
|
progressTracker.setPossibleLowestTimestamp(Math.min(sample.timestamp, sample.decodingTimestamp ?? Infinity));
|
|
await state.addSample({
|
|
chunk: sample,
|
|
trackNumber: videoTrack.trackNumber,
|
|
isVideo: true,
|
|
codecPrivate: track.codecData?.data ?? null
|
|
});
|
|
onMediaStateUpdate?.((prevState) => {
|
|
return {
|
|
...prevState,
|
|
decodedVideoFrames: prevState.decodedVideoFrames + 1
|
|
};
|
|
});
|
|
};
|
|
};
|
|
|
|
// src/default-on-video-track-handler.ts
|
|
import { MediaParserInternals as MediaParserInternals8 } from "@remotion/media-parser";
|
|
var defaultOnVideoTrackHandler = async ({
|
|
track,
|
|
defaultVideoCodec,
|
|
logLevel,
|
|
rotate,
|
|
canCopyTrack,
|
|
resizeOperation
|
|
}) => {
|
|
if (canCopyTrack) {
|
|
MediaParserInternals8.Log.verbose(logLevel, `Track ${track.trackId} (video): Can copy, therefore copying`);
|
|
return Promise.resolve({ type: "copy" });
|
|
}
|
|
if (defaultVideoCodec === null) {
|
|
MediaParserInternals8.Log.verbose(logLevel, `Track ${track.trackId} (video): Is audio container, therefore dropping video`);
|
|
return Promise.resolve({ type: "drop" });
|
|
}
|
|
const canReencode = await canReencodeVideoTrack({
|
|
videoCodec: defaultVideoCodec,
|
|
track,
|
|
resizeOperation,
|
|
rotate
|
|
});
|
|
if (canReencode) {
|
|
MediaParserInternals8.Log.verbose(logLevel, `Track ${track.trackId} (video): Cannot copy, but re-enconde, therefore re-encoding`);
|
|
return Promise.resolve({
|
|
type: "reencode",
|
|
videoCodec: defaultVideoCodec,
|
|
rotate: undefined,
|
|
resize: resizeOperation
|
|
});
|
|
}
|
|
MediaParserInternals8.Log.verbose(logLevel, `Track ${track.trackId} (video): Can neither copy nor re-encode, therefore failing`);
|
|
return Promise.resolve({ type: "fail" });
|
|
};
|
|
|
|
// src/get-default-video-codec.ts
|
|
var getDefaultVideoCodec = ({
|
|
container
|
|
}) => {
|
|
if (container === "webm") {
|
|
return "vp8";
|
|
}
|
|
if (container === "mp4") {
|
|
return "h264";
|
|
}
|
|
if (container === "wav") {
|
|
return null;
|
|
}
|
|
throw new Error(`Unhandled container: ${container}`);
|
|
};
|
|
|
|
// src/arraybuffer-to-uint8-array.ts
|
|
var arrayBufferToUint8Array = (buffer) => {
|
|
return buffer ? new Uint8Array(buffer) : null;
|
|
};
|
|
|
|
// src/create-video-decoder.ts
|
|
var internalCreateVideoDecoder = async ({
|
|
onFrame,
|
|
onError,
|
|
controller,
|
|
config,
|
|
logLevel
|
|
}) => {
|
|
if (controller && controller._internals._mediaParserController._internals.signal.aborted) {
|
|
throw new Error("Not creating audio decoder, already aborted");
|
|
}
|
|
const ioSynchronizer = makeIoSynchronizer({
|
|
logLevel,
|
|
label: "Video decoder",
|
|
controller
|
|
});
|
|
let mostRecentSampleReceived = null;
|
|
const videoDecoder = new VideoDecoder({
|
|
async output(frame) {
|
|
try {
|
|
await onFrame(frame);
|
|
} catch (err) {
|
|
onError(err);
|
|
frame.close();
|
|
}
|
|
ioSynchronizer.onOutput(frame.timestamp);
|
|
},
|
|
error(error) {
|
|
onError(error);
|
|
}
|
|
});
|
|
const close = () => {
|
|
if (controller) {
|
|
controller._internals._mediaParserController._internals.signal.removeEventListener("abort", onAbort);
|
|
}
|
|
if (videoDecoder.state === "closed") {
|
|
return;
|
|
}
|
|
videoDecoder.close();
|
|
};
|
|
const onAbort = () => {
|
|
close();
|
|
};
|
|
if (controller) {
|
|
controller._internals._mediaParserController._internals.signal.addEventListener("abort", onAbort);
|
|
}
|
|
const isConfigSupported = await VideoDecoder.isConfigSupported(config);
|
|
if (!isConfigSupported) {
|
|
throw new VideoUndecodableError({
|
|
message: "Video cannot be decoded by this browser",
|
|
config
|
|
});
|
|
}
|
|
videoDecoder.configure(config);
|
|
const decode = async (sample) => {
|
|
if (videoDecoder.state === "closed") {
|
|
return;
|
|
}
|
|
try {
|
|
await controller?._internals._mediaParserController._internals.checkForAbortAndPause();
|
|
} catch (err) {
|
|
onError(err);
|
|
return;
|
|
}
|
|
mostRecentSampleReceived = sample.timestamp;
|
|
const encodedChunk = sample instanceof EncodedVideoChunk ? sample : new EncodedVideoChunk(sample);
|
|
videoDecoder.decode(encodedChunk);
|
|
ioSynchronizer.inputItem(sample.timestamp);
|
|
};
|
|
let flushPending = null;
|
|
let lastReset = null;
|
|
return {
|
|
decode,
|
|
close,
|
|
flush: () => {
|
|
if (flushPending) {
|
|
throw new Error("Flush already pending");
|
|
}
|
|
const pendingFlush = makeFlushPending();
|
|
flushPending = pendingFlush;
|
|
Promise.resolve().then(() => {
|
|
return videoDecoder.flush();
|
|
}).catch(() => {}).finally(() => {
|
|
pendingFlush.resolve();
|
|
flushPending = null;
|
|
});
|
|
return pendingFlush.promise;
|
|
},
|
|
waitForQueueToBeLessThan: ioSynchronizer.waitForQueueSize,
|
|
reset: () => {
|
|
lastReset = Date.now();
|
|
flushPending?.resolve();
|
|
ioSynchronizer.clearQueue();
|
|
videoDecoder.reset();
|
|
videoDecoder.configure(config);
|
|
},
|
|
checkReset: () => {
|
|
const initTime = Date.now();
|
|
return {
|
|
wasReset: () => lastReset !== null && lastReset > initTime
|
|
};
|
|
},
|
|
getMostRecentSampleInput() {
|
|
return mostRecentSampleReceived;
|
|
}
|
|
};
|
|
};
|
|
var createVideoDecoder = ({
|
|
onFrame,
|
|
onError,
|
|
controller,
|
|
track,
|
|
logLevel
|
|
}) => {
|
|
return internalCreateVideoDecoder({
|
|
onFrame,
|
|
onError,
|
|
controller: controller ?? null,
|
|
config: track,
|
|
logLevel: logLevel ?? "info"
|
|
});
|
|
};
|
|
|
|
// src/convert-to-correct-videoframe.ts
|
|
var needsToCorrectVideoFrame = ({
|
|
videoFrame,
|
|
outputCodec
|
|
}) => {
|
|
if (videoFrame.format === null) {
|
|
return true;
|
|
}
|
|
if (videoFrame.format === "I420P10") {
|
|
return true;
|
|
}
|
|
return isFirefox() && videoFrame.format === "BGRX" && outputCodec === "h264";
|
|
};
|
|
var convertToCorrectVideoFrame = ({
|
|
videoFrame,
|
|
outputCodec
|
|
}) => {
|
|
if (!needsToCorrectVideoFrame({ videoFrame, outputCodec })) {
|
|
return videoFrame;
|
|
}
|
|
const canvas = new OffscreenCanvas(videoFrame.displayWidth, videoFrame.displayHeight);
|
|
canvas.width = videoFrame.displayWidth;
|
|
canvas.height = videoFrame.displayHeight;
|
|
const ctx = canvas.getContext("2d");
|
|
if (!ctx) {
|
|
throw new Error("Could not get 2d context");
|
|
}
|
|
ctx.drawImage(videoFrame, 0, 0);
|
|
return new VideoFrame(canvas, {
|
|
displayHeight: videoFrame.displayHeight,
|
|
displayWidth: videoFrame.displayWidth,
|
|
duration: videoFrame.duration,
|
|
timestamp: videoFrame.timestamp
|
|
});
|
|
};
|
|
|
|
// src/on-frame.ts
|
|
var processFrame = async ({
|
|
frame: unrotatedFrame,
|
|
onVideoFrame,
|
|
track,
|
|
outputCodec,
|
|
rotation,
|
|
resizeOperation
|
|
}) => {
|
|
const rotated = rotateAndResizeVideoFrame({
|
|
rotation,
|
|
frame: unrotatedFrame,
|
|
resizeOperation,
|
|
needsToBeMultipleOfTwo: outputCodec === "h264"
|
|
});
|
|
if (unrotatedFrame !== rotated) {
|
|
unrotatedFrame.close();
|
|
}
|
|
const userProcessedFrame = onVideoFrame ? await onVideoFrame({ frame: rotated, track }) : rotated;
|
|
if (userProcessedFrame.displayWidth !== rotated.displayWidth) {
|
|
throw new Error(`Returned VideoFrame of track ${track.trackId} has different displayWidth (${userProcessedFrame.displayWidth}) than the input frame (${rotated.displayWidth})`);
|
|
}
|
|
if (userProcessedFrame.displayHeight !== rotated.displayHeight) {
|
|
throw new Error(`Returned VideoFrame of track ${track.trackId} has different displayHeight (${userProcessedFrame.displayHeight}) than the input frame (${rotated.displayHeight})`);
|
|
}
|
|
if (userProcessedFrame.timestamp !== rotated.timestamp && !isSafari()) {
|
|
throw new Error(`Returned VideoFrame of track ${track.trackId} has different timestamp (${userProcessedFrame.timestamp}) than the input frame (${rotated.timestamp}). When calling new VideoFrame(), pass {timestamp: frame.timestamp} as second argument`);
|
|
}
|
|
if ((userProcessedFrame.duration ?? 0) !== (rotated.duration ?? 0)) {
|
|
throw new Error(`Returned VideoFrame of track ${track.trackId} has different duration (${userProcessedFrame.duration}) than the input frame (${rotated.duration}). When calling new VideoFrame(), pass {duration: frame.duration} as second argument`);
|
|
}
|
|
if (rotated !== userProcessedFrame) {
|
|
rotated.close();
|
|
}
|
|
const fixedFrame = convertToCorrectVideoFrame({
|
|
videoFrame: userProcessedFrame,
|
|
outputCodec
|
|
});
|
|
if (fixedFrame !== userProcessedFrame) {
|
|
userProcessedFrame.close();
|
|
}
|
|
return fixedFrame;
|
|
};
|
|
|
|
// src/sort-video-frames.ts
|
|
var MAX_QUEUE_SIZE = 5;
|
|
var videoFrameSorter = ({
|
|
controller,
|
|
onOutput
|
|
}) => {
|
|
const frames = [];
|
|
const releaseFrame = async () => {
|
|
await controller._internals._mediaParserController._internals.checkForAbortAndPause();
|
|
const frame = frames.shift();
|
|
if (frame) {
|
|
await onOutput(frame);
|
|
}
|
|
};
|
|
const sortFrames = () => {
|
|
frames.sort((a, b) => a.timestamp - b.timestamp);
|
|
};
|
|
const releaseIfQueueFull = async () => {
|
|
if (frames.length >= MAX_QUEUE_SIZE) {
|
|
sortFrames();
|
|
await releaseFrame();
|
|
}
|
|
};
|
|
const addFrame = (frame) => {
|
|
frames.push(frame);
|
|
};
|
|
const inputFrame = async (frame) => {
|
|
addFrame(frame);
|
|
await releaseIfQueueFull();
|
|
};
|
|
const onAbort = () => {
|
|
while (frames.length > 0) {
|
|
const frame = frames.shift();
|
|
if (frame) {
|
|
frame.close();
|
|
}
|
|
}
|
|
frames.length = 0;
|
|
};
|
|
const flush = async () => {
|
|
sortFrames();
|
|
while (frames.length > 0) {
|
|
await releaseFrame();
|
|
}
|
|
controller._internals._mediaParserController._internals.signal.removeEventListener("abort", onAbort);
|
|
};
|
|
controller._internals._mediaParserController._internals.signal.addEventListener("abort", onAbort);
|
|
let promise = Promise.resolve();
|
|
return {
|
|
inputFrame: (frame) => {
|
|
promise = promise.then(() => inputFrame(frame));
|
|
},
|
|
waitUntilProcessed: () => promise,
|
|
flush
|
|
};
|
|
};
|
|
|
|
// src/video-encoder.ts
|
|
import {
|
|
MediaParserAbortError as MediaParserAbortError2
|
|
} from "@remotion/media-parser";
|
|
var createVideoEncoder = ({
|
|
onChunk,
|
|
onError,
|
|
controller,
|
|
config,
|
|
logLevel,
|
|
outputCodec,
|
|
keyframeInterval
|
|
}) => {
|
|
if (controller._internals._mediaParserController._internals.signal.aborted) {
|
|
throw new MediaParserAbortError2("Not creating video encoder, already aborted");
|
|
}
|
|
const ioSynchronizer = makeIoSynchronizer({
|
|
logLevel,
|
|
label: "Video encoder",
|
|
controller
|
|
});
|
|
const encoder = new VideoEncoder({
|
|
error(error) {
|
|
onError(error);
|
|
},
|
|
async output(chunk, metadata) {
|
|
const timestamp = chunk.timestamp + (chunk.duration ?? 0);
|
|
try {
|
|
await onChunk(chunk, metadata ?? null);
|
|
} catch (err) {
|
|
onError(err);
|
|
}
|
|
ioSynchronizer.onOutput(timestamp);
|
|
}
|
|
});
|
|
const close = () => {
|
|
controller._internals._mediaParserController._internals.signal.removeEventListener("abort", onAbort);
|
|
if (encoder.state === "closed") {
|
|
return;
|
|
}
|
|
encoder.close();
|
|
};
|
|
const onAbort = () => {
|
|
close();
|
|
};
|
|
controller._internals._mediaParserController._internals.signal.addEventListener("abort", onAbort);
|
|
Log.verbose(logLevel, "Configuring video encoder", config);
|
|
encoder.configure(config);
|
|
let framesProcessed = 0;
|
|
const encodeFrame = (frame) => {
|
|
if (encoder.state === "closed") {
|
|
return;
|
|
}
|
|
const keyFrame = framesProcessed % keyframeInterval === 0;
|
|
encoder.encode(convertToCorrectVideoFrame({ videoFrame: frame, outputCodec }), {
|
|
keyFrame,
|
|
vp9: {
|
|
quantizer: 36
|
|
}
|
|
});
|
|
ioSynchronizer.inputItem(frame.timestamp);
|
|
framesProcessed++;
|
|
};
|
|
return {
|
|
encode: (frame) => {
|
|
encodeFrame(frame);
|
|
},
|
|
waitForFinish: async () => {
|
|
await encoder.flush();
|
|
await ioSynchronizer.waitForQueueSize(0);
|
|
},
|
|
close,
|
|
flush: async () => {
|
|
await encoder.flush();
|
|
},
|
|
ioSynchronizer
|
|
};
|
|
};
|
|
|
|
// src/reencode-video-track.ts
|
|
var reencodeVideoTrack = async ({
|
|
videoOperation,
|
|
rotate,
|
|
track,
|
|
logLevel,
|
|
abortConversion,
|
|
onMediaStateUpdate,
|
|
controller,
|
|
onVideoFrame,
|
|
state,
|
|
progressTracker
|
|
}) => {
|
|
if (videoOperation.type !== "reencode") {
|
|
throw new Error(`Video track with ID ${track.trackId} could not be resolved with a valid operation. Received ${JSON.stringify(videoOperation)}, but must be either "copy", "reencode", "drop" or "fail"`);
|
|
}
|
|
const rotation = videoOperation.rotate ?? rotate;
|
|
const { height: newHeight, width: newWidth } = calculateNewDimensionsFromRotateAndScale({
|
|
width: track.codedWidth,
|
|
height: track.codedHeight,
|
|
rotation,
|
|
needsToBeMultipleOfTwo: videoOperation.videoCodec === "h264",
|
|
resizeOperation: videoOperation.resize ?? null
|
|
});
|
|
const videoEncoderConfig = await getVideoEncoderConfig({
|
|
codec: videoOperation.videoCodec,
|
|
height: newHeight,
|
|
width: newWidth,
|
|
fps: track.fps
|
|
});
|
|
const videoDecoderConfig = await getVideoDecoderConfigWithHardwareAcceleration(track);
|
|
Log.verbose(logLevel, "Video encoder config", videoEncoderConfig);
|
|
Log.verbose(logLevel, "Video decoder config", videoDecoderConfig ?? track);
|
|
if (videoEncoderConfig === null) {
|
|
abortConversion(new Error(`Could not configure video encoder of track ${track.trackId}`));
|
|
return null;
|
|
}
|
|
if (videoDecoderConfig === null) {
|
|
abortConversion(new Error(`Could not configure video decoder of track ${track.trackId}`));
|
|
return null;
|
|
}
|
|
const { trackNumber } = await state.addTrack({
|
|
type: "video",
|
|
color: track.advancedColor,
|
|
width: newWidth,
|
|
height: newHeight,
|
|
codec: videoOperation.videoCodec,
|
|
codecPrivate: null,
|
|
timescale: track.originalTimescale
|
|
});
|
|
Log.verbose(logLevel, `Created new video track with ID ${trackNumber}, codec ${videoOperation.videoCodec} and timescale ${track.originalTimescale}`);
|
|
const videoEncoder = createVideoEncoder({
|
|
onChunk: async (chunk, metadata) => {
|
|
await state.addSample({
|
|
chunk: convertEncodedChunk(chunk),
|
|
trackNumber,
|
|
isVideo: true,
|
|
codecPrivate: arrayBufferToUint8Array(metadata?.decoderConfig?.description ?? null)
|
|
});
|
|
onMediaStateUpdate?.((prevState) => {
|
|
return {
|
|
...prevState,
|
|
encodedVideoFrames: prevState.encodedVideoFrames + 1
|
|
};
|
|
});
|
|
},
|
|
onError: (err) => {
|
|
abortConversion(new Error(`Video encoder of track ${track.trackId} failed (see .cause of this error)`, {
|
|
cause: err
|
|
}));
|
|
},
|
|
controller,
|
|
config: videoEncoderConfig,
|
|
logLevel,
|
|
outputCodec: videoOperation.videoCodec,
|
|
keyframeInterval: 40
|
|
});
|
|
const videoProcessingQueue = processingQueue({
|
|
controller,
|
|
label: "VideoFrame processing queue",
|
|
logLevel,
|
|
onError: (err) => {
|
|
abortConversion(new Error(`VideoFrame processing queue of track ${track.trackId} failed (see .cause of this error)`, {
|
|
cause: err
|
|
}));
|
|
},
|
|
onOutput: async (frame) => {
|
|
await controller._internals._mediaParserController._internals.checkForAbortAndPause();
|
|
const processedFrame = await processFrame({
|
|
frame,
|
|
track,
|
|
onVideoFrame,
|
|
outputCodec: videoOperation.videoCodec,
|
|
rotation,
|
|
resizeOperation: videoOperation.resize ?? null
|
|
});
|
|
await controller._internals._mediaParserController._internals.checkForAbortAndPause();
|
|
await videoEncoder.ioSynchronizer.waitForQueueSize(10);
|
|
await controller._internals._mediaParserController._internals.checkForAbortAndPause();
|
|
videoEncoder.encode(processedFrame);
|
|
processedFrame.close();
|
|
}
|
|
});
|
|
const frameSorter = videoFrameSorter({
|
|
controller,
|
|
onOutput: async (frame) => {
|
|
await controller._internals._mediaParserController._internals.checkForAbortAndPause();
|
|
await videoProcessingQueue.ioSynchronizer.waitForQueueSize(10);
|
|
videoProcessingQueue.input(frame);
|
|
}
|
|
});
|
|
const videoDecoder = await createVideoDecoder({
|
|
track: videoDecoderConfig,
|
|
onFrame: async (frame) => {
|
|
await frameSorter.waitUntilProcessed();
|
|
frameSorter.inputFrame(frame);
|
|
},
|
|
onError: (err) => {
|
|
abortConversion(new Error(`Video decoder of track ${track.trackId} failed (see .cause of this error)`, {
|
|
cause: err
|
|
}));
|
|
},
|
|
controller,
|
|
logLevel
|
|
});
|
|
state.addWaitForFinishPromise(async () => {
|
|
Log.verbose(logLevel, "Waiting for video decoder to finish");
|
|
await videoDecoder.flush();
|
|
videoDecoder.close();
|
|
Log.verbose(logLevel, "Video decoder finished. Waiting for encoder to finish");
|
|
await frameSorter.flush();
|
|
Log.verbose(logLevel, "Frame sorter flushed");
|
|
await videoProcessingQueue.ioSynchronizer.waitForQueueSize(0);
|
|
Log.verbose(logLevel, "Video processing queue finished");
|
|
await videoEncoder.waitForFinish();
|
|
videoEncoder.close();
|
|
Log.verbose(logLevel, "Video encoder finished");
|
|
});
|
|
return async (chunk) => {
|
|
progressTracker.setPossibleLowestTimestamp(Math.min(chunk.timestamp, chunk.decodingTimestamp ?? Infinity));
|
|
await controller._internals._mediaParserController._internals.checkForAbortAndPause();
|
|
await videoDecoder.waitForQueueToBeLessThan(15);
|
|
if (chunk.type === "key") {
|
|
await videoDecoder.flush();
|
|
}
|
|
videoDecoder.decode(chunk);
|
|
};
|
|
};
|
|
|
|
// src/on-video-track.ts
|
|
var makeVideoTrackHandler = ({
|
|
state,
|
|
onVideoFrame,
|
|
onMediaStateUpdate,
|
|
abortConversion,
|
|
controller,
|
|
defaultVideoCodec,
|
|
onVideoTrack,
|
|
logLevel,
|
|
outputContainer,
|
|
rotate,
|
|
resizeOperation,
|
|
progressTracker
|
|
}) => async ({ track, container: inputContainer }) => {
|
|
if (controller._internals._mediaParserController._internals.signal.aborted) {
|
|
throw new Error("Aborted");
|
|
}
|
|
const canCopyTrack = canCopyVideoTrack({
|
|
inputContainer,
|
|
outputContainer,
|
|
rotationToApply: rotate,
|
|
inputTrack: track,
|
|
resizeOperation,
|
|
outputVideoCodec: defaultVideoCodec
|
|
});
|
|
const videoOperation = await (onVideoTrack ?? defaultOnVideoTrackHandler)({
|
|
track,
|
|
defaultVideoCodec: defaultVideoCodec ?? getDefaultVideoCodec({ container: outputContainer }),
|
|
logLevel,
|
|
outputContainer,
|
|
rotate,
|
|
inputContainer,
|
|
canCopyTrack,
|
|
resizeOperation
|
|
});
|
|
if (videoOperation.type === "drop") {
|
|
return null;
|
|
}
|
|
if (videoOperation.type === "fail") {
|
|
throw new Error(`Video track with ID ${track.trackId} resolved with {"type": "fail"}. This could mean that this video track could neither be copied to the output container or re-encoded. You have the option to drop the track instead of failing it: https://remotion.dev/docs/webcodecs/track-transformation`);
|
|
}
|
|
if (videoOperation.type === "copy") {
|
|
return copyVideoTrack({
|
|
logLevel,
|
|
onMediaStateUpdate,
|
|
state,
|
|
track,
|
|
progressTracker
|
|
});
|
|
}
|
|
return reencodeVideoTrack({
|
|
videoOperation,
|
|
abortConversion,
|
|
controller,
|
|
logLevel,
|
|
rotate,
|
|
track,
|
|
onVideoFrame,
|
|
state,
|
|
onMediaStateUpdate,
|
|
progressTracker
|
|
});
|
|
};
|
|
|
|
// src/throttled-state-update.ts
|
|
var throttledStateUpdate = ({
|
|
updateFn,
|
|
everyMilliseconds,
|
|
signal
|
|
}) => {
|
|
let currentState = {
|
|
decodedAudioFrames: 0,
|
|
decodedVideoFrames: 0,
|
|
encodedVideoFrames: 0,
|
|
encodedAudioFrames: 0,
|
|
bytesWritten: 0,
|
|
millisecondsWritten: 0,
|
|
expectedOutputDurationInMs: null,
|
|
overallProgress: 0
|
|
};
|
|
if (!updateFn) {
|
|
return {
|
|
get: () => currentState,
|
|
update: null,
|
|
stopAndGetLastProgress: () => {}
|
|
};
|
|
}
|
|
let lastUpdated = null;
|
|
const callUpdateIfChanged = () => {
|
|
if (currentState === lastUpdated) {
|
|
return;
|
|
}
|
|
updateFn(currentState);
|
|
lastUpdated = currentState;
|
|
};
|
|
const interval = setInterval(() => {
|
|
callUpdateIfChanged();
|
|
}, everyMilliseconds);
|
|
const onAbort = () => {
|
|
clearInterval(interval);
|
|
};
|
|
signal?.addEventListener("abort", onAbort, { once: true });
|
|
return {
|
|
get: () => currentState,
|
|
update: (fn) => {
|
|
currentState = fn(currentState);
|
|
},
|
|
stopAndGetLastProgress: () => {
|
|
clearInterval(interval);
|
|
signal?.removeEventListener("abort", onAbort);
|
|
return currentState;
|
|
}
|
|
};
|
|
};
|
|
|
|
// src/webcodecs-controller.ts
|
|
import { mediaParserController } from "@remotion/media-parser";
|
|
var webcodecsController = () => {
|
|
const controller = mediaParserController();
|
|
return {
|
|
abort: controller.abort,
|
|
pause: controller.pause,
|
|
resume: controller.resume,
|
|
addEventListener: controller.addEventListener,
|
|
removeEventListener: controller.removeEventListener,
|
|
_internals: { _mediaParserController: controller }
|
|
};
|
|
};
|
|
|
|
// src/convert-media.ts
|
|
var convertMedia = async function({
|
|
src,
|
|
onVideoFrame,
|
|
onAudioData,
|
|
onProgress: onProgressDoNotCallDirectly,
|
|
audioCodec,
|
|
container,
|
|
videoCodec,
|
|
controller = webcodecsController(),
|
|
onAudioTrack: userAudioResolver,
|
|
onVideoTrack: userVideoResolver,
|
|
reader,
|
|
fields,
|
|
logLevel = "info",
|
|
writer,
|
|
progressIntervalInMs,
|
|
rotate,
|
|
resize,
|
|
onAudioCodec,
|
|
onContainer,
|
|
onDimensions,
|
|
onDurationInSeconds,
|
|
onFps,
|
|
onImages,
|
|
onInternalStats,
|
|
onIsHdr,
|
|
onKeyframes,
|
|
onLocation,
|
|
onMetadata,
|
|
onMimeType,
|
|
onName,
|
|
onNumberOfAudioChannels,
|
|
onRotation,
|
|
onSampleRate,
|
|
onSize,
|
|
onSlowAudioBitrate,
|
|
onSlowDurationInSeconds,
|
|
onSlowFps,
|
|
onSlowKeyframes,
|
|
onSlowNumberOfFrames,
|
|
onSlowVideoBitrate,
|
|
onSlowStructure,
|
|
onTracks,
|
|
onUnrotatedDimensions,
|
|
onVideoCodec,
|
|
onM3uStreams,
|
|
selectM3uStream,
|
|
selectM3uAssociatedPlaylists,
|
|
expectedDurationInSeconds,
|
|
expectedFrameRate,
|
|
seekingHints,
|
|
...more
|
|
}) {
|
|
if (controller._internals._mediaParserController._internals.signal.aborted) {
|
|
return Promise.reject(new MediaParserAbortError3("Aborted"));
|
|
}
|
|
if (availableContainers.indexOf(container) === -1) {
|
|
return Promise.reject(new TypeError(`Only the following values for "container" are supported currently: ${JSON.stringify(availableContainers)}`));
|
|
}
|
|
if (videoCodec && availableVideoCodecs.indexOf(videoCodec) === -1) {
|
|
return Promise.reject(new TypeError(`Only the following values for "videoCodec" are supported currently: ${JSON.stringify(availableVideoCodecs)}`));
|
|
}
|
|
const { resolve, reject, getPromiseToImmediatelyReturn } = withResolversAndWaitForReturn();
|
|
const abortConversion = (errCause) => {
|
|
reject(errCause);
|
|
if (!controller._internals._mediaParserController._internals.signal.aborted) {
|
|
controller.abort();
|
|
}
|
|
};
|
|
const onUserAbort = () => {
|
|
abortConversion(new MediaParserAbortError3("Conversion aborted by user"));
|
|
};
|
|
controller._internals._mediaParserController._internals.signal.addEventListener("abort", onUserAbort);
|
|
const throttledState = throttledStateUpdate({
|
|
updateFn: onProgressDoNotCallDirectly ?? null,
|
|
everyMilliseconds: progressIntervalInMs ?? 100,
|
|
signal: controller._internals._mediaParserController._internals.signal
|
|
});
|
|
const progressTracker = makeProgressTracker();
|
|
const state = await createMedia({
|
|
container,
|
|
filename: generateOutputFilename(src, container),
|
|
writer: await autoSelectWriter(writer, logLevel),
|
|
onBytesProgress: (bytesWritten) => {
|
|
throttledState.update?.((prevState) => {
|
|
return {
|
|
...prevState,
|
|
bytesWritten
|
|
};
|
|
});
|
|
},
|
|
onMillisecondsProgress: (millisecondsWritten) => {
|
|
throttledState.update?.((prevState) => {
|
|
if (millisecondsWritten > prevState.millisecondsWritten) {
|
|
return {
|
|
...prevState,
|
|
millisecondsWritten,
|
|
overallProgress: calculateProgress({
|
|
millisecondsWritten: prevState.millisecondsWritten,
|
|
expectedOutputDurationInMs: prevState.expectedOutputDurationInMs
|
|
})
|
|
};
|
|
}
|
|
return prevState;
|
|
});
|
|
},
|
|
logLevel,
|
|
progressTracker,
|
|
expectedDurationInSeconds: expectedDurationInSeconds ?? null,
|
|
expectedFrameRate: expectedFrameRate ?? null
|
|
});
|
|
const onVideoTrack = makeVideoTrackHandler({
|
|
progressTracker,
|
|
state,
|
|
onVideoFrame: onVideoFrame ?? null,
|
|
onMediaStateUpdate: throttledState.update ?? null,
|
|
abortConversion,
|
|
controller,
|
|
defaultVideoCodec: videoCodec ?? null,
|
|
onVideoTrack: userVideoResolver ?? null,
|
|
logLevel,
|
|
outputContainer: container,
|
|
rotate: rotate ?? 0,
|
|
resizeOperation: resize ?? null
|
|
});
|
|
const onAudioTrack = makeAudioTrackHandler({
|
|
progressTracker,
|
|
abortConversion,
|
|
defaultAudioCodec: audioCodec ?? null,
|
|
controller,
|
|
onMediaStateUpdate: throttledState.update ?? null,
|
|
state,
|
|
onAudioTrack: userAudioResolver ?? null,
|
|
logLevel,
|
|
outputContainer: container,
|
|
onAudioData: onAudioData ?? null
|
|
});
|
|
MediaParserInternals9.internalParseMedia({
|
|
logLevel,
|
|
src,
|
|
onVideoTrack,
|
|
onAudioTrack,
|
|
controller: controller._internals._mediaParserController,
|
|
fields: {
|
|
...fields,
|
|
durationInSeconds: true
|
|
},
|
|
reader: reader ?? webReader,
|
|
...more,
|
|
onDurationInSeconds: (durationInSeconds) => {
|
|
if (durationInSeconds === null) {
|
|
return null;
|
|
}
|
|
const casted = more;
|
|
if (casted.onDurationInSeconds) {
|
|
casted.onDurationInSeconds(durationInSeconds);
|
|
}
|
|
const expectedOutputDurationInMs = durationInSeconds * 1000;
|
|
throttledState.update?.((prevState) => {
|
|
return {
|
|
...prevState,
|
|
expectedOutputDurationInMs,
|
|
overallProgress: calculateProgress({
|
|
millisecondsWritten: prevState.millisecondsWritten,
|
|
expectedOutputDurationInMs
|
|
})
|
|
};
|
|
});
|
|
},
|
|
acknowledgeRemotionLicense: true,
|
|
mode: "query",
|
|
onDiscardedData: null,
|
|
onError: () => ({ action: "fail" }),
|
|
onParseProgress: null,
|
|
progressIntervalInMs: null,
|
|
onAudioCodec: onAudioCodec ?? null,
|
|
onContainer: onContainer ?? null,
|
|
onDimensions: onDimensions ?? null,
|
|
onFps: onFps ?? null,
|
|
onImages: onImages ?? null,
|
|
onInternalStats: onInternalStats ?? null,
|
|
onIsHdr: onIsHdr ?? null,
|
|
onKeyframes: onKeyframes ?? null,
|
|
onLocation: onLocation ?? null,
|
|
onMetadata: onMetadata ?? null,
|
|
onMimeType: onMimeType ?? null,
|
|
onName: onName ?? null,
|
|
onNumberOfAudioChannels: onNumberOfAudioChannels ?? null,
|
|
onRotation: onRotation ?? null,
|
|
onSampleRate: onSampleRate ?? null,
|
|
onSize: onSize ?? null,
|
|
onSlowAudioBitrate: onSlowAudioBitrate ?? null,
|
|
onSlowDurationInSeconds: onSlowDurationInSeconds ?? null,
|
|
onSlowFps: onSlowFps ?? null,
|
|
onSlowKeyframes: onSlowKeyframes ?? null,
|
|
onSlowNumberOfFrames: onSlowNumberOfFrames ?? null,
|
|
onSlowVideoBitrate: onSlowVideoBitrate ?? null,
|
|
onSlowStructure: onSlowStructure ?? null,
|
|
onTracks: onTracks ?? null,
|
|
onUnrotatedDimensions: onUnrotatedDimensions ?? null,
|
|
onVideoCodec: onVideoCodec ?? null,
|
|
apiName: "convertMedia()",
|
|
onM3uStreams: onM3uStreams ?? null,
|
|
selectM3uStream: selectM3uStream ?? defaultSelectM3uStreamFn,
|
|
selectM3uAssociatedPlaylists: selectM3uAssociatedPlaylists ?? defaultSelectM3uAssociatedPlaylists,
|
|
makeSamplesStartAtZero: false,
|
|
m3uPlaylistContext: null,
|
|
seekingHints: seekingHints ?? null
|
|
}).then(() => {
|
|
return state.waitForFinish();
|
|
}).then(() => {
|
|
resolve({
|
|
save: state.getBlob,
|
|
remove: state.remove,
|
|
finalState: throttledState.get()
|
|
});
|
|
}).then(() => {}).catch((err) => {
|
|
reject(err);
|
|
}).finally(() => {
|
|
throttledState.stopAndGetLastProgress();
|
|
});
|
|
return getPromiseToImmediatelyReturn().finally(() => {
|
|
controller._internals._mediaParserController._internals.signal.removeEventListener("abort", onUserAbort);
|
|
});
|
|
};
|
|
// src/extract-frames.ts
|
|
import { parseMedia } from "@remotion/media-parser";
|
|
|
|
// src/internal-extract-frames.ts
|
|
import {
|
|
MediaParserAbortError as MediaParserAbortError4,
|
|
WEBCODECS_TIMESCALE,
|
|
hasBeenAborted,
|
|
mediaParserController as mediaParserController2
|
|
} from "@remotion/media-parser";
|
|
var internalExtractFrames = ({
|
|
src,
|
|
onFrame,
|
|
signal,
|
|
timestampsInSeconds,
|
|
acknowledgeRemotionLicense,
|
|
logLevel,
|
|
parseMediaImplementation
|
|
}) => {
|
|
const controller = mediaParserController2();
|
|
const expectedFrames = [];
|
|
const resolvers = withResolvers();
|
|
const abortListener = () => {
|
|
controller.abort();
|
|
resolvers.reject(new MediaParserAbortError4("Aborted by user"));
|
|
};
|
|
signal?.addEventListener("abort", abortListener, { once: true });
|
|
let dur = null;
|
|
let lastFrame;
|
|
let lastFrameEmitted;
|
|
parseMediaImplementation({
|
|
src: new URL(src, window.location.href),
|
|
acknowledgeRemotionLicense,
|
|
controller,
|
|
logLevel,
|
|
onDurationInSeconds(durationInSeconds) {
|
|
dur = durationInSeconds;
|
|
},
|
|
onVideoTrack: async ({ track, container }) => {
|
|
const timestampTargetsUnsorted = typeof timestampsInSeconds === "function" ? await timestampsInSeconds({
|
|
track,
|
|
container,
|
|
durationInSeconds: dur
|
|
}) : timestampsInSeconds;
|
|
const timestampTargets = timestampTargetsUnsorted.sort((a, b) => a - b);
|
|
if (timestampTargets.length === 0) {
|
|
throw new Error("expected at least one timestamp to extract but found zero");
|
|
}
|
|
controller.seek(timestampTargets[0]);
|
|
const decoder = await createVideoDecoder({
|
|
onFrame: (frame) => {
|
|
Log.trace(logLevel, "Received frame with timestamp", frame.timestamp);
|
|
if (expectedFrames.length === 0) {
|
|
frame.close();
|
|
return;
|
|
}
|
|
if (frame.timestamp < expectedFrames[0] - 1) {
|
|
if (lastFrame) {
|
|
lastFrame.close();
|
|
}
|
|
lastFrame = frame;
|
|
return;
|
|
}
|
|
if (expectedFrames[0] + 6667 < frame.timestamp && lastFrame && lastFrame !== lastFrameEmitted) {
|
|
onFrame(lastFrame);
|
|
lastFrameEmitted = lastFrame;
|
|
expectedFrames.shift();
|
|
lastFrame = frame;
|
|
return;
|
|
}
|
|
expectedFrames.shift();
|
|
onFrame(frame);
|
|
if (lastFrame && lastFrame !== lastFrameEmitted) {
|
|
lastFrame.close();
|
|
}
|
|
lastFrameEmitted = frame;
|
|
lastFrame = frame;
|
|
},
|
|
onError: (e) => {
|
|
controller.abort();
|
|
try {
|
|
decoder.close();
|
|
} catch {}
|
|
resolvers.reject(e);
|
|
},
|
|
track
|
|
});
|
|
const queued = [];
|
|
const doProcess = async () => {
|
|
expectedFrames.push(timestampTargets.shift() * WEBCODECS_TIMESCALE);
|
|
while (queued.length > 0) {
|
|
const sam = queued.shift();
|
|
if (!sam) {
|
|
throw new Error("Sample is undefined");
|
|
}
|
|
await decoder.waitForQueueToBeLessThan(20);
|
|
Log.trace(logLevel, "Decoding sample", sam.timestamp);
|
|
await decoder.decode(sam);
|
|
}
|
|
};
|
|
return async (sample) => {
|
|
const nextTimestampWeWant = timestampTargets[0];
|
|
Log.trace(logLevel, `Received ${sample.type} sample with dts`, sample.decodingTimestamp, "and cts", sample.timestamp);
|
|
if (sample.type === "key") {
|
|
await decoder.flush();
|
|
queued.length = 0;
|
|
}
|
|
queued.push(sample);
|
|
if (sample.decodingTimestamp >= timestampTargets[timestampTargets.length - 1] * WEBCODECS_TIMESCALE) {
|
|
await doProcess();
|
|
await decoder.flush();
|
|
controller.abort();
|
|
return;
|
|
}
|
|
if (nextTimestampWeWant === undefined) {
|
|
throw new Error("this should not happen");
|
|
}
|
|
if (sample.decodingTimestamp >= nextTimestampWeWant * WEBCODECS_TIMESCALE) {
|
|
await doProcess();
|
|
if (timestampTargets.length === 0) {
|
|
await decoder.flush();
|
|
controller.abort();
|
|
}
|
|
}
|
|
return async () => {
|
|
await doProcess();
|
|
await decoder.flush();
|
|
if (lastFrame && lastFrameEmitted !== lastFrame) {
|
|
lastFrame.close();
|
|
}
|
|
};
|
|
};
|
|
}
|
|
}).then(() => {
|
|
resolvers.resolve();
|
|
}).catch((e) => {
|
|
if (!hasBeenAborted(e)) {
|
|
resolvers.reject(e);
|
|
} else {
|
|
resolvers.resolve();
|
|
}
|
|
}).finally(() => {
|
|
if (lastFrame && lastFrameEmitted !== lastFrame) {
|
|
lastFrame.close();
|
|
}
|
|
signal?.removeEventListener("abort", abortListener);
|
|
});
|
|
return resolvers.promise;
|
|
};
|
|
|
|
// src/extract-frames.ts
|
|
var extractFrames = (options) => {
|
|
return internalExtractFrames({
|
|
...options,
|
|
signal: options.signal ?? null,
|
|
acknowledgeRemotionLicense: options.acknowledgeRemotionLicense ?? false,
|
|
logLevel: options.logLevel ?? "info",
|
|
parseMediaImplementation: parseMedia
|
|
});
|
|
};
|
|
// src/get-available-audio-codecs.ts
|
|
var getAvailableAudioCodecs = ({
|
|
container
|
|
}) => {
|
|
if (container === "mp4") {
|
|
return ["aac"];
|
|
}
|
|
if (container === "webm") {
|
|
return ["opus"];
|
|
}
|
|
if (container === "wav") {
|
|
return ["wav"];
|
|
}
|
|
throw new Error(`Unsupported container: ${container}`);
|
|
};
|
|
// src/get-partial-audio-data.ts
|
|
import {
|
|
hasBeenAborted as hasBeenAborted2,
|
|
mediaParserController as mediaParserController3,
|
|
parseMedia as parseMedia2
|
|
} from "@remotion/media-parser";
|
|
var extractOverlappingAudioSamples = ({
|
|
sample,
|
|
fromSeconds,
|
|
toSeconds,
|
|
channelIndex,
|
|
timescale: timescale2
|
|
}) => {
|
|
const chunkStartInSeconds = sample.timestamp / timescale2;
|
|
const chunkDuration = sample.numberOfFrames / sample.sampleRate;
|
|
const chunkEndInSeconds = chunkStartInSeconds + chunkDuration;
|
|
const overlapStartSecond = Math.max(chunkStartInSeconds, fromSeconds);
|
|
const overlapEndSecond = Math.min(chunkEndInSeconds, toSeconds);
|
|
if (overlapStartSecond >= overlapEndSecond) {
|
|
return null;
|
|
}
|
|
const { numberOfChannels } = sample;
|
|
const samplesPerChannel = sample.numberOfFrames;
|
|
let data;
|
|
if (numberOfChannels === 1) {
|
|
data = new Float32Array(samplesPerChannel);
|
|
sample.copyTo(data, { format: "f32", planeIndex: 0 });
|
|
} else {
|
|
const interleaved = new Float32Array(samplesPerChannel * numberOfChannels);
|
|
sample.copyTo(interleaved, { format: "f32", planeIndex: 0 });
|
|
data = new Float32Array(samplesPerChannel);
|
|
for (let i = 0;i < samplesPerChannel; i++) {
|
|
data[i] = interleaved[i * numberOfChannels + channelIndex];
|
|
}
|
|
}
|
|
const startSampleInChunk = Math.floor((overlapStartSecond - chunkStartInSeconds) * sample.sampleRate);
|
|
const endSampleInChunk = Math.ceil((overlapEndSecond - chunkStartInSeconds) * sample.sampleRate);
|
|
return data.slice(startSampleInChunk, endSampleInChunk);
|
|
};
|
|
var BUFFER_IN_SECONDS = 0.1;
|
|
var getPartialAudioData = async ({
|
|
src,
|
|
fromSeconds,
|
|
toSeconds,
|
|
channelIndex,
|
|
signal
|
|
}) => {
|
|
const controller = mediaParserController3();
|
|
const audioSamples = [];
|
|
if (signal.aborted) {
|
|
throw new Error("Operation was aborted");
|
|
}
|
|
const { resolve: resolveAudioDecode, promise: audioDecodePromise } = Promise.withResolvers();
|
|
const onAbort = () => {
|
|
controller.abort();
|
|
resolveAudioDecode();
|
|
};
|
|
signal.addEventListener("abort", onAbort, { once: true });
|
|
try {
|
|
const seekFromSeconds = Math.max(0, fromSeconds - BUFFER_IN_SECONDS);
|
|
if (seekFromSeconds > 0) {
|
|
controller.seek(seekFromSeconds);
|
|
}
|
|
await parseMedia2({
|
|
acknowledgeRemotionLicense: true,
|
|
src,
|
|
controller,
|
|
onAudioTrack: async ({ track }) => {
|
|
if (signal.aborted) {
|
|
return null;
|
|
}
|
|
const audioDecoder = await createAudioDecoder({
|
|
track,
|
|
onFrame: (sample) => {
|
|
if (signal.aborted) {
|
|
sample.close();
|
|
return;
|
|
}
|
|
const trimmedData = extractOverlappingAudioSamples({
|
|
sample,
|
|
fromSeconds,
|
|
toSeconds,
|
|
channelIndex,
|
|
timescale: track.timescale
|
|
});
|
|
if (trimmedData) {
|
|
audioSamples.push(trimmedData);
|
|
}
|
|
sample.close();
|
|
},
|
|
onError(error) {
|
|
resolveAudioDecode();
|
|
throw error;
|
|
}
|
|
});
|
|
return async (sample) => {
|
|
if (signal.aborted) {
|
|
audioDecoder.close();
|
|
controller.abort();
|
|
return;
|
|
}
|
|
if (!audioDecoder) {
|
|
throw new Error("No audio decoder found");
|
|
}
|
|
const fromSecondsWithBuffer = Math.max(0, fromSeconds - BUFFER_IN_SECONDS);
|
|
const toSecondsWithBuffer = toSeconds + BUFFER_IN_SECONDS;
|
|
const time = sample.timestamp / track.timescale;
|
|
if (time < fromSecondsWithBuffer) {
|
|
return;
|
|
}
|
|
if (time >= toSecondsWithBuffer) {
|
|
audioDecoder.flush().then(() => {
|
|
audioDecoder.close();
|
|
resolveAudioDecode();
|
|
});
|
|
controller.abort();
|
|
return;
|
|
}
|
|
await audioDecoder.waitForQueueToBeLessThan(10);
|
|
audioDecoder.decode(sample);
|
|
return () => {
|
|
audioDecoder.flush().then(() => {
|
|
audioDecoder.close();
|
|
resolveAudioDecode();
|
|
});
|
|
};
|
|
};
|
|
}
|
|
});
|
|
} catch (err) {
|
|
const isAbortedByTimeCutoff = hasBeenAborted2(err);
|
|
if (!isAbortedByTimeCutoff && !signal.aborted) {
|
|
throw err;
|
|
}
|
|
} finally {
|
|
signal.removeEventListener("abort", onAbort);
|
|
}
|
|
await audioDecodePromise;
|
|
const totalSamples = audioSamples.reduce((sum, sample) => sum + sample.length, 0);
|
|
const result = new Float32Array(totalSamples);
|
|
let offset = 0;
|
|
for (const audioSample of audioSamples) {
|
|
result.set(audioSample, offset);
|
|
offset += audioSample.length;
|
|
}
|
|
return result;
|
|
};
|
|
|
|
// src/index.ts
|
|
var WebCodecsInternals = {
|
|
rotateAndResizeVideoFrame,
|
|
normalizeVideoRotation
|
|
};
|
|
export {
|
|
webcodecsController,
|
|
rotateAndResizeVideoFrame,
|
|
getPartialAudioData,
|
|
getDefaultVideoCodec,
|
|
getDefaultAudioCodec,
|
|
getAvailableVideoCodecs,
|
|
getAvailableContainers,
|
|
getAvailableAudioCodecs,
|
|
extractFrames,
|
|
defaultOnVideoTrackHandler,
|
|
defaultOnAudioTrackHandler,
|
|
createVideoEncoder,
|
|
createVideoDecoder,
|
|
createAudioEncoder,
|
|
createAudioDecoder,
|
|
convertMedia,
|
|
convertAudioData,
|
|
canReencodeVideoTrack,
|
|
canReencodeAudioTrack,
|
|
canCopyVideoTrack,
|
|
canCopyAudioTrack,
|
|
WebCodecsInternals,
|
|
VideoUndecodableError,
|
|
AudioUndecodableError
|
|
};
|