@remotion/media-parser
Version:
A pure JavaScript library for parsing video files
483 lines (475 loc) • 14.3 kB
JavaScript
// src/errors.ts
class MediaParserAbortError extends Error {
constructor(message) {
super(message);
this.name = "MediaParserAbortError";
this.cause = undefined;
}
}
// src/log.ts
var logLevels = ["trace", "verbose", "info", "warn", "error"];
var getNumberForLogLevel = (level) => {
return logLevels.indexOf(level);
};
var isEqualOrBelowLogLevel = (currentLevel, level) => {
return getNumberForLogLevel(currentLevel) <= getNumberForLogLevel(level);
};
var Log = {
trace: (logLevel, ...args) => {
if (isEqualOrBelowLogLevel(logLevel, "trace")) {
return console.log(...args);
}
},
verbose: (logLevel, ...args) => {
if (isEqualOrBelowLogLevel(logLevel, "verbose")) {
return console.log(...args);
}
},
info: (logLevel, ...args) => {
if (isEqualOrBelowLogLevel(logLevel, "info")) {
return console.log(...args);
}
},
warn: (logLevel, ...args) => {
if (isEqualOrBelowLogLevel(logLevel, "warn")) {
return console.warn(...args);
}
},
error: (...args) => {
return console.error(...args);
}
};
// src/readers/fetch/get-body-and-reader.ts
var getLengthAndReader = async ({
canLiveWithoutContentLength,
res,
ownController,
requestedWithoutRange
}) => {
const length = res.headers.get("content-length");
const contentLength = length === null ? null : parseInt(length, 10);
if (requestedWithoutRange || canLiveWithoutContentLength && contentLength === null) {
const buffer = await res.arrayBuffer();
const encoded = new Uint8Array(buffer);
let streamCancelled = false;
const stream = new ReadableStream({
start(controller) {
if (ownController.signal.aborted) {
return;
}
if (streamCancelled) {
return;
}
try {
controller.enqueue(encoded);
controller.close();
} catch {}
},
cancel() {
streamCancelled = true;
}
});
return {
contentLength: encoded.byteLength,
reader: {
reader: stream.getReader(),
abort: () => {
ownController.abort();
return Promise.resolve();
}
},
needsContentRange: false
};
}
if (!res.body) {
throw new Error("No body");
}
const reader = res.body.getReader();
return {
reader: {
reader,
abort: () => {
ownController.abort();
return Promise.resolve();
}
},
contentLength,
needsContentRange: true
};
};
// src/readers/fetch/resolve-url.ts
var resolveUrl = (src) => {
try {
const resolvedUrl = typeof window !== "undefined" && typeof window.location !== "undefined" ? new URL(src, window.location.origin) : new URL(src);
return resolvedUrl;
} catch {
return src;
}
};
// src/readers/from-fetch.ts
function parseContentRange(input) {
const matches = input.match(/^(\w+) ((\d+)-(\d+)|\*)\/(\d+|\*)$/);
if (!matches)
return null;
const [, unit, , start, end, size] = matches;
const range = {
unit,
start: start != null ? Number(start) : null,
end: end != null ? Number(end) : null,
size: size === "*" ? null : Number(size)
};
if (range.start === null && range.end === null && range.size === null) {
return null;
}
return range;
}
var validateContentRangeAndDetectIfSupported = ({
requestedRange,
parsedContentRange,
statusCode
}) => {
if (statusCode === 206) {
return { supportsContentRange: true };
}
if (typeof requestedRange === "number" && parsedContentRange?.start !== requestedRange) {
if (requestedRange === 0) {
return { supportsContentRange: false };
}
throw new Error(`Range header (${requestedRange}) does not match content-range header (${parsedContentRange?.start})`);
}
if (requestedRange !== null && typeof requestedRange !== "number" && (parsedContentRange?.start !== requestedRange[0] || parsedContentRange?.end !== requestedRange[1])) {
throw new Error(`Range header (${requestedRange}) does not match content-range header (${parsedContentRange?.start})`);
}
return { supportsContentRange: true };
};
var makeFetchRequest = async ({
range,
src,
controller
}) => {
const resolvedUrl = resolveUrl(src);
const resolvedUrlString = resolvedUrl.toString();
if (!resolvedUrlString.startsWith("https://") && !resolvedUrlString.startsWith("blob:") && !resolvedUrlString.startsWith("http://")) {
return Promise.reject(new Error(`${resolvedUrlString} is not a URL - needs to start with http:// or https:// or blob:. If you want to read a local file, pass \`reader: nodeReader\` to parseMedia().`));
}
const ownController = new AbortController;
const cache = typeof navigator !== "undefined" && navigator.userAgent.includes("Cloudflare-Workers") ? undefined : "no-store";
const requestedRange = range === null ? 0 : range;
const asString = typeof resolvedUrl === "string" ? resolvedUrl : resolvedUrl.pathname;
const requestWithoutRange = asString.endsWith(".m3u8");
const canLiveWithoutContentLength = asString.endsWith(".m3u8") || asString.endsWith(".ts");
const headers = requestedRange === 0 && requestWithoutRange ? {} : typeof requestedRange === "number" ? {
Range: `bytes=${requestedRange}-`
} : {
Range: `bytes=${`${requestedRange[0]}-${requestedRange[1]}`}`
};
const res = await fetch(resolvedUrl, {
headers,
signal: ownController.signal,
cache
});
const contentRange = res.headers.get("content-range");
const parsedContentRange = contentRange ? parseContentRange(contentRange) : null;
const { supportsContentRange } = validateContentRangeAndDetectIfSupported({
requestedRange,
parsedContentRange,
statusCode: res.status
});
if (controller) {
controller._internals.signal.addEventListener("abort", () => {
ownController.abort(new MediaParserAbortError("Aborted by user"));
}, { once: true });
}
if (res.status.toString().startsWith("4") || res.status.toString().startsWith("5")) {
throw new Error(`Server returned status code ${res.status} for ${resolvedUrl} and range ${requestedRange}`);
}
const contentDisposition = res.headers.get("content-disposition");
const name = contentDisposition?.match(/filename="([^"]+)"/)?.[1];
const { contentLength, needsContentRange, reader } = await getLengthAndReader({
canLiveWithoutContentLength,
res,
ownController,
requestedWithoutRange: requestWithoutRange
});
const contentType = res.headers.get("content-type");
return {
contentLength,
needsContentRange,
reader,
name,
contentType,
supportsContentRange
};
};
var cacheKey = ({
src,
range
}) => {
return `${src}-${JSON.stringify(range)}`;
};
var makeFetchRequestOrGetCached = ({
range,
src,
controller,
logLevel,
prefetchCache
}) => {
const key = cacheKey({ src, range });
const cached = prefetchCache.get(key);
if (cached) {
Log.verbose(logLevel, `Reading from preload cache for ${key}`);
return cached;
}
Log.verbose(logLevel, `Fetching ${key}`);
const result = makeFetchRequest({ range, src, controller });
prefetchCache.set(key, result);
return result;
};
var fetchReadContent = async ({
src,
range,
controller,
logLevel,
prefetchCache
}) => {
if (typeof src !== "string" && src instanceof URL === false) {
throw new Error("src must be a string when using `fetchReader`");
}
const fallbackName = src.toString().split("/").pop();
const res = makeFetchRequestOrGetCached({
range,
src,
controller,
logLevel,
prefetchCache
});
const key = cacheKey({ src, range });
prefetchCache.delete(key);
const {
reader,
contentLength,
needsContentRange,
name,
supportsContentRange,
contentType
} = await res;
if (controller) {
controller._internals.signal.addEventListener("abort", () => {
reader.reader.cancel().catch(() => {});
}, { once: true });
}
return {
reader,
contentLength,
contentType,
name: name ?? fallbackName,
supportsContentRange,
needsContentRange
};
};
var fetchPreload = ({
src,
range,
logLevel,
prefetchCache
}) => {
if (typeof src !== "string" && src instanceof URL === false) {
throw new Error("src must be a string when using `fetchReader`");
}
const key = cacheKey({ src, range });
if (prefetchCache.has(key)) {
return prefetchCache.get(key);
}
makeFetchRequestOrGetCached({
range,
src,
controller: null,
logLevel,
prefetchCache
});
};
var fetchReadWholeAsText = async (src) => {
if (typeof src !== "string" && src instanceof URL === false) {
throw new Error("src must be a string when using `fetchReader`");
}
const res = await fetch(src);
if (!res.ok) {
throw new Error(`Failed to fetch ${src} (HTTP code: ${res.status})`);
}
return res.text();
};
var fetchCreateAdjacentFileSource = (relativePath, src) => {
if (typeof src !== "string" && src instanceof URL === false) {
throw new Error("src must be a string or URL when using `fetchReader`");
}
return new URL(relativePath, src).toString();
};
// src/readers/from-node.ts
import { createReadStream, existsSync, promises, statSync } from "fs";
import { dirname, join, relative, sep } from "path";
var nodeReadContent = async ({
src,
range,
controller
}) => {
if (typeof src !== "string") {
throw new Error("src must be a string when using `nodeReader`");
}
await Promise.resolve();
const ownController = new AbortController;
try {
if (!existsSync(src)) {
throw new Error(`File does not exist: ${src}`);
}
const stream = createReadStream(src, {
start: range === null ? 0 : typeof range === "number" ? range : range[0],
end: range === null ? Infinity : typeof range === "number" ? Infinity : range[1]
});
controller._internals.signal.addEventListener("abort", () => {
ownController.abort();
}, { once: true });
const stats = statSync(src);
let readerCancelled = false;
const reader = new ReadableStream({
start(c) {
if (readerCancelled) {
return;
}
stream.on("data", (chunk) => {
c.enqueue(chunk);
});
stream.on("end", () => {
if (readerCancelled) {
return;
}
c.close();
});
stream.on("error", (err) => {
c.error(err);
});
},
cancel() {
readerCancelled = true;
stream.destroy();
}
}).getReader();
if (controller) {
controller._internals.signal.addEventListener("abort", () => {
reader.cancel().catch(() => {});
}, { once: true });
}
return Promise.resolve({
reader: {
reader,
abort: async () => {
try {
stream.destroy();
ownController.abort();
await reader.cancel();
} catch {}
}
},
contentLength: stats.size,
contentType: null,
name: src.split(sep).pop(),
supportsContentRange: true,
needsContentRange: true
});
} catch (err) {
return Promise.reject(err);
}
};
var nodeReadWholeAsText = (src) => {
if (typeof src !== "string") {
throw new Error("src must be a string when using `nodeReader`");
}
return promises.readFile(src, "utf8");
};
var nodeCreateAdjacentFileSource = (relativePath, src) => {
if (typeof src !== "string") {
throw new Error("src must be a string when using `nodeReader`");
}
const result = join(dirname(src), relativePath);
const rel = relative(dirname(src), result);
if (rel.startsWith("..")) {
throw new Error("Path is outside of the parent directory - not allowing reading of arbitrary files");
}
return result;
};
// src/readers/from-web-file.ts
var webFileReadContent = ({ src, range, controller }) => {
if (typeof src === "string" || src instanceof URL) {
throw new Error("`inputTypeFileReader` only supports `File` objects");
}
const part = range === null ? src : typeof range === "number" ? src.slice(range) : src.slice(range[0], range[1] + 1);
const stream = part.stream();
const streamReader = stream.getReader();
if (controller) {
controller._internals.signal.addEventListener("abort", () => {
streamReader.cancel();
}, { once: true });
}
return Promise.resolve({
reader: {
reader: streamReader,
async abort() {
try {
await streamReader.cancel();
} catch {}
return Promise.resolve();
}
},
contentLength: src.size,
name: src instanceof File ? src.name : src.toString(),
supportsContentRange: true,
contentType: src.type,
needsContentRange: true
});
};
var webFileReadWholeAsText = () => {
throw new Error("`webFileReader` cannot read auxiliary files.");
};
var webFileCreateAdjacentFileSource = () => {
throw new Error("`webFileReader` cannot create adjacent file sources.");
};
// src/readers/universal.ts
var universalReader = {
read: (params) => {
if (params.src instanceof Blob) {
return webFileReadContent(params);
}
if (params.src.toString().startsWith("http") || params.src.toString().startsWith("blob:")) {
return fetchReadContent(params);
}
return nodeReadContent(params);
},
readWholeAsText: (src) => {
if (src instanceof Blob) {
return webFileReadWholeAsText(src);
}
if (src.toString().startsWith("http") || src.toString().startsWith("blob:")) {
return fetchReadWholeAsText(src);
}
return nodeReadWholeAsText(src);
},
createAdjacentFileSource: (relativePath, src) => {
if (src instanceof Blob) {
return webFileCreateAdjacentFileSource(relativePath, src);
}
if (src.toString().startsWith("http") || src.toString().startsWith("blob:")) {
return fetchCreateAdjacentFileSource(relativePath, src);
}
return nodeCreateAdjacentFileSource(relativePath, src);
},
preload: ({ src, range, logLevel, prefetchCache }) => {
if (src instanceof Blob) {
return;
}
if (src.toString().startsWith("http") || src.toString().startsWith("blob:")) {
return fetchPreload({ range, src, logLevel, prefetchCache });
}
}
};
export {
universalReader
};