mirror of
https://github.com/arcodange-org/mediabunny.git
synced 2026-10-02 21:33:52 +02:00
Add WebVTT subtitle support to MP4 & a bunch of misc changes
This commit is contained in:
+60
-83
@@ -1,62 +1,46 @@
|
||||
export type EncodedSubtitleChunk = {
|
||||
body: Uint8Array,
|
||||
additions: Uint8Array | null,
|
||||
timestamp: number,
|
||||
duration: number
|
||||
export type SubtitleCue = {
|
||||
timestamp: number, // in seconds
|
||||
duration: number, // in seconds
|
||||
text: string,
|
||||
identifier?: string,
|
||||
settings?: string,
|
||||
notes?: string
|
||||
};
|
||||
|
||||
export type SubtitleDecoderConfig = {
|
||||
description: Uint8Array
|
||||
export type SubtitleConfig = {
|
||||
description: string
|
||||
};
|
||||
|
||||
export type EncodedSubtitleChunkMetadata = {
|
||||
decoderConfig?: SubtitleDecoderConfig
|
||||
export type SubtitleMetadata = {
|
||||
config?: SubtitleConfig
|
||||
};
|
||||
|
||||
interface SubtitleEncoderOptions {
|
||||
output: (chunk: EncodedSubtitleChunk, metadata: EncodedSubtitleChunkMetadata) => unknown,
|
||||
type SubtitleParserOptions = {
|
||||
codec: 'webvtt',
|
||||
output: (cue: SubtitleCue, metadata: SubtitleMetadata) => unknown,
|
||||
error: (error: Error) => unknown
|
||||
}
|
||||
|
||||
interface SubtitleEncoderConfig {
|
||||
codec: 'webvtt'
|
||||
}
|
||||
};
|
||||
|
||||
const cueBlockHeaderRegex = /(?:(.+?)\n)?((?:\d{2}:)?\d{2}:\d{2}.\d{3})\s+-->\s+((?:\d{2}:)?\d{2}:\d{2}.\d{3})/g;
|
||||
const preambleStartRegex = /^WEBVTT.*?\n{2}/;
|
||||
const timestampRegex = /(?:(\d{2}):)?(\d{2}):(\d{2}).(\d{3})/;
|
||||
const inlineTimestampRegex = /<(?:(\d{2}):)?(\d{2}):(\d{2}).(\d{3})>/g;
|
||||
const textEncoder = new TextEncoder();
|
||||
const preambleStartRegex = /^WEBVTT(.|\n)*?\n{2}/;
|
||||
export const inlineTimestampRegex = /<(?:(\d{2}):)?(\d{2}):(\d{2}).(\d{3})>/g;
|
||||
|
||||
export class SubtitleEncoder {
|
||||
#options: SubtitleEncoderOptions;
|
||||
#config: SubtitleEncoderConfig | null = null;
|
||||
#preambleBytes: Uint8Array | null = null;
|
||||
export class SubtitleParser {
|
||||
#options: SubtitleParserOptions;
|
||||
#preambleText: string | null = null;
|
||||
#preambleEmitted = false;
|
||||
|
||||
constructor(options: SubtitleEncoderOptions) {
|
||||
constructor(options: SubtitleParserOptions) {
|
||||
this.#options = options;
|
||||
}
|
||||
|
||||
configure(config: SubtitleEncoderConfig) {
|
||||
if (config.codec !== 'webvtt') {
|
||||
throw new Error("Codec must be 'webvtt'.");
|
||||
}
|
||||
|
||||
this.#config = config;
|
||||
}
|
||||
|
||||
encode(text: string) {
|
||||
if (!this.#config) {
|
||||
throw new Error('Encoder not configured.');
|
||||
}
|
||||
|
||||
text = text.replace('\r\n', '\n').replace('\r', '\n');
|
||||
parse(text: string) {
|
||||
text = text.replaceAll('\r\n', '\n').replaceAll('\r', '\n');
|
||||
|
||||
cueBlockHeaderRegex.lastIndex = 0;
|
||||
let match: RegExpMatchArray | null;
|
||||
|
||||
if (!this.#preambleBytes) {
|
||||
if (!this.#preambleText) {
|
||||
if (!preambleStartRegex.test(text)) {
|
||||
let error = new Error('WebVTT preamble incorrect.');
|
||||
this.#options.error(error);
|
||||
@@ -72,7 +56,7 @@ export class SubtitleEncoder {
|
||||
throw error;
|
||||
}
|
||||
|
||||
this.#preambleBytes = textEncoder.encode(preamble);
|
||||
this.#preambleText = preamble;
|
||||
|
||||
if (match) {
|
||||
text = text.slice(match.index);
|
||||
@@ -82,70 +66,63 @@ export class SubtitleEncoder {
|
||||
|
||||
while (match = cueBlockHeaderRegex.exec(text)) {
|
||||
let notes = text.slice(0, match.index);
|
||||
let cueIdentifier = match[1] || '';
|
||||
let cueIdentifier = match[1];
|
||||
let matchEnd = match.index! + match[0].length;
|
||||
let bodyStart = text.indexOf('\n', matchEnd) + 1;
|
||||
let cueSettings = text.slice(matchEnd, bodyStart).trim();
|
||||
let bodyEnd = text.indexOf('\n\n', matchEnd);
|
||||
if (bodyEnd === -1) bodyEnd = text.length;
|
||||
|
||||
let startTime = this.#parseTimestamp(match[2]!);
|
||||
let endTime = this.#parseTimestamp(match[3]!);
|
||||
let startTime = parseSubtitleTimestamp(match[2]!);
|
||||
let endTime = parseSubtitleTimestamp(match[3]!);
|
||||
let duration = endTime - startTime;
|
||||
|
||||
let body = text.slice(bodyStart, bodyEnd);
|
||||
let additions = `${cueSettings}\n${cueIdentifier}\n${notes}`;
|
||||
|
||||
// Replace in-body timestamps so that they're relative to the cue start time
|
||||
inlineTimestampRegex.lastIndex = 0;
|
||||
body = body.replace(inlineTimestampRegex, (match) => {
|
||||
let time = this.#parseTimestamp(match.slice(1, -1));
|
||||
let offsetTime = time - startTime;
|
||||
|
||||
return `<${this.#formatTimestamp(offsetTime)}>`;
|
||||
});
|
||||
let body = text.slice(bodyStart, bodyEnd).trim();
|
||||
|
||||
text = text.slice(bodyEnd).trimStart();
|
||||
cueBlockHeaderRegex.lastIndex = 0;
|
||||
|
||||
let chunk: EncodedSubtitleChunk = {
|
||||
body: textEncoder.encode(body),
|
||||
additions: additions.trim() === '' ? null : textEncoder.encode(additions),
|
||||
timestamp: startTime * 1000,
|
||||
duration: duration * 1000
|
||||
let cue: SubtitleCue = {
|
||||
timestamp: startTime / 1000,
|
||||
duration: duration / 1000,
|
||||
text: body,
|
||||
identifier: cueIdentifier,
|
||||
settings: cueSettings,
|
||||
notes
|
||||
};
|
||||
|
||||
let meta: EncodedSubtitleChunkMetadata = {};
|
||||
let meta: SubtitleMetadata = {};
|
||||
if (!this.#preambleEmitted) {
|
||||
meta.decoderConfig = {
|
||||
description: this.#preambleBytes
|
||||
meta.config = {
|
||||
description: this.#preambleText
|
||||
};
|
||||
this.#preambleEmitted = true;
|
||||
}
|
||||
|
||||
this.#options.output(chunk, meta);
|
||||
this.#options.output(cue, meta);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#parseTimestamp(string: string) {
|
||||
let match = timestampRegex.exec(string);
|
||||
if (!match) throw new Error('Expected match.');
|
||||
const timestampRegex = /(?:(\d{2}):)?(\d{2}):(\d{2}).(\d{3})/;
|
||||
export const parseSubtitleTimestamp = (string: string) => {
|
||||
let match = timestampRegex.exec(string);
|
||||
if (!match) throw new Error('Expected match.');
|
||||
|
||||
return 60 * 60 * 1000 * Number(match[1] || '0') +
|
||||
60 * 1000 * Number(match[2]) +
|
||||
1000 * Number(match[3]) +
|
||||
Number(match[4]);
|
||||
}
|
||||
return 60 * 60 * 1000 * Number(match[1] || '0') +
|
||||
60 * 1000 * Number(match[2]) +
|
||||
1000 * Number(match[3]) +
|
||||
Number(match[4]);
|
||||
};
|
||||
|
||||
#formatTimestamp(timestamp: number) {
|
||||
let hours = Math.floor(timestamp / (60 * 60 * 1000));
|
||||
let minutes = Math.floor((timestamp % (60 * 60 * 1000)) / (60 * 1000));
|
||||
let seconds = Math.floor((timestamp % (60 * 1000)) / 1000);
|
||||
let milliseconds = timestamp % 1000;
|
||||
export const formatSubtitleTimestamp = (timestamp: number) => {
|
||||
let hours = Math.floor(timestamp / (60 * 60 * 1000));
|
||||
let minutes = Math.floor((timestamp % (60 * 60 * 1000)) / (60 * 1000));
|
||||
let seconds = Math.floor((timestamp % (60 * 1000)) / 1000);
|
||||
let milliseconds = timestamp % 1000;
|
||||
|
||||
return hours.toString().padStart(2, '0') + ':' +
|
||||
minutes.toString().padStart(2, '0') + ':' +
|
||||
seconds.toString().padStart(2, '0') + '.' +
|
||||
milliseconds.toString().padStart(3, '0');
|
||||
}
|
||||
}
|
||||
return hours.toString().padStart(2, '0') + ':' +
|
||||
minutes.toString().padStart(2, '0') + ':' +
|
||||
seconds.toString().padStart(2, '0') + '.' +
|
||||
milliseconds.toString().padStart(3, '0');
|
||||
};
|
||||
Reference in New Issue
Block a user