ffmeta
Version:
A pure JavaScript implementation of ffmetadata parsing and serialization
146 lines (144 loc) • 5.96 kB
JavaScript
function parse(source) {
// https://github.com/FFmpeg/FFmpeg/blob/master/libavformat/ffmetadec.c
// Convert source to a string and split on unescaped newlines.
const lines = splitLines(`${source}`);
// The metadata of the current section, may be the global
// section, a stream or a chapter.
let metadata = Object.create(null);
const chapters = [];
const streams = [];
const ffmetadata = { metadata, streams, chapters };
const length = lines.length;
const lastIndex = length - 1;
for (let i = 0; i < length; i++) {
let line = lines[i];
// https://github.com/FFmpeg/FFmpeg/blob/master/libavformat/ffmetadec.c#L184
if (line.startsWith("[STREAM]" /* ID_STREAM */)) {
metadata = Object.create(null);
streams.push({ metadata });
}
else if (line.startsWith("[CHAPTER]" /* ID_CHAPTER */)) {
// Parse the START, END and optionally TIMESTAMP values of the chapter.
// Read the next line or throw a syntax error if there are no more lines.
if (i === lastIndex)
throw new SyntaxError('Expected chapter start timestamp, found EOF');
line = lines[++i];
// TIMEBASE is optional, if the line doesn't match it will be parsed as START.
let TIMEBASE;
const timebaseMatch = line.match(/^TIMEBASE=([0-9]+)\\*\/([0-9]+)/);
if (timebaseMatch !== null) {
const [, num, den] = timebaseMatch;
TIMEBASE = `${num}/${den}`;
if (i === lastIndex)
throw new SyntaxError('Expected chapter start timestamp, found EOF');
line = lines[++i];
}
// START and END are not optional, if the lines don't match throw a syntax error.
const startMatch = line.match(/^START=([0-9]+)/);
if (startMatch === null)
throw new SyntaxError(`Expected chapter start timestamp, found ${line}`);
const START = startMatch[1];
if (i === lastIndex)
throw new SyntaxError('Expected chapter end timestamp, found EOF');
line = lines[++i];
const endMatch = line.match(/^END=([0-9]+)/);
if (endMatch === null)
throw new SyntaxError(`Expected chapter end timestamp, found ${line}`);
const END = endMatch[1];
metadata = Object.create(null);
chapters.push({ TIMEBASE, START, END, metadata });
}
else {
const length = line.length;
// Parse a tag.
for (let i = 0; i < length; i++) {
const c = line[i];
// Read until the first unescaped `=`.
if (c === '=') {
const key = unescapeMetaComponent(line.slice(0, i));
const value = unescapeMetaComponent(line.slice(i + 1));
metadata[key] = value;
break;
}
else if (c === '\\') {
// The next character is escaped, skip it.
i++;
}
}
}
}
return ffmetadata;
}
function stringify(ffmetadata) {
// https://github.com/FFmpeg/FFmpeg/blob/master/libavformat/ffmetaenc.c
const metadata = stringifyTags(ffmetadata.metadata);
const streams = ffmetadata.streams
.map(({ metadata }) => `${"[STREAM]" /* ID_STREAM */}\n${stringifyTags(metadata)}`)
.join('');
const chapters = ffmetadata.chapters
.map(({ TIMEBASE, START, END, metadata }) => {
let timebase;
if (TIMEBASE !== void 0 && TIMEBASE !== null) {
timebase = `${TIMEBASE}`;
// TIMEBASE is optional, when given it must be a fraction.
if (!/^[0-9]+\/[0-9]+$/.test(timebase))
throw new TypeError(`${timebase} is not a valid timebase fraction`);
}
// START and END must be strings containing an integer.
const start = `${START}`;
if (!isInt(start))
throw new TypeError(`${start} is not a valid start timestamp`);
const end = `${END}`;
if (!isInt(end))
throw new TypeError(`${end} is not a valid end timestamp`);
return `${"[CHAPTER]" /* ID_CHAPTER */}\n${timebase ? `TIMEBASE=${timebase}\n` : ''}START=${start}\nEND=${end}\n${stringifyTags(metadata)}`;
})
.join('');
return `${";FFMETADATA" /* ID_STRING */}1\n${metadata}${streams}${chapters}`;
}
function isInt(s) {
return /^[0-9]+$/.test(s);
}
function stringifyTags(tags) {
return Object.entries(tags)
.filter(([, value]) => value !== void 0 && value !== null)
.map(([key, value]) => `${escapeMetaComponent(key)}=${escapeMetaComponent(`${value}`)}\n`)
.join('');
}
function splitLines(source) {
const lines = [];
// Adjusted for bug 9144, a bug in libavformat that makes
// escaping inconsistent with \n (newline characters).
// A backslash at the end of a value cannot be escaped properly.
let prev;
let offset = 0;
let i = 0;
const length = source.length;
for (; i < length; i++) {
const c = source[i];
if (prev !== '\\' && (c === '\n' || c === '\r' || c === '\0')) {
const line = source.slice(offset, i);
if (isNonEmpty(line))
lines.push(line);
offset = i + 1; // Skip \n
}
prev = c;
}
if (offset !== i) {
const line = source.slice(offset, i);
if (isNonEmpty(line))
lines.push(line);
}
return lines;
}
function isNonEmpty(line) {
let c;
return line !== '' && (c = line[0]) !== ';' && c !== '#';
}
function escapeMetaComponent(s) {
return s.replace(/[=;#\\\n]/g, '\\$&');
}
function unescapeMetaComponent(s) {
return s.replace(/\\(.|\n|\r)/g, '$1');
}
export { parse, stringify };