Skip to content

Commit

Permalink
refactor(subtitles): remove custom ssa parsing (#2514)
Browse files Browse the repository at this point in the history
With the recent switch to ass.js there's no reason for maintaining custom ass parsing anymore as it handles most cases. The removal will also speed up the loading of ass subtitles as they wont need to be parsed and loaded twice before rendering.
  • Loading branch information
seanmcbroom authored Nov 29, 2024
1 parent 19aa23a commit 944506a
Show file tree
Hide file tree
Showing 3 changed files with 27 additions and 229 deletions.
3 changes: 1 addition & 2 deletions frontend/src/plugins/workers/generic.worker.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { expose } from 'comlink';
import { parseSsaFile, parseVttFile } from './generic/subtitles';
import { parseVttFile } from './generic/subtitles';
import { sealed } from '@/utils/validation';

/**
Expand All @@ -26,7 +26,6 @@ class GenericWorker {
* Functions for parsing subtitles
*/
public parseVttFile = parseVttFile;
public parseSsaFile = parseSsaFile;
}

const instance = new GenericWorker();
Expand Down
178 changes: 0 additions & 178 deletions frontend/src/plugins/workers/generic/subtitles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,181 +103,3 @@ export async function parseVttFile(src: string) {
console.error('Error parsing VTT subtitles', error);
}
}

const parseFormatFields = (line: string) => line.split('Format:')[1].split(',').map(field => field.trim());

/**
* Extracts text from the SSA
*/
function parseFormattedLine(line: string, formatFields: string[]) {
const lineParts = line.slice(line.indexOf(':') + 1, -1).split(',').map(field => field.trim());
const lineData: Record<string, string> = {};

for (const [fieldIndex, field] of formatFields.entries()) {
lineData[field] = field === 'Text'
? lineParts.slice(fieldIndex).join(', ').trim() // Add dialogue together
: lineParts[fieldIndex].trim();
}

return lineData;
};

/**
* Extracts styles from the SSA file
*/
function parseSsaStyles(lines: string[]) {
let formatFields: string[] = [];
const styles = [];

for (const line of lines) {
if (line.startsWith('Format:')) {
formatFields = parseFormatFields(line);
} else if (line.startsWith('Style:')) {
const style = parseFormattedLine(line, formatFields);

styles.push(style);
}
}

return styles;
};

/**
* Parses dialogue line from SSA file.
*/
function parseSsaDialogue(line: string, formatFields: string[]): Dialogue {
const dialogueData = parseFormattedLine(line, formatFields);

const timeStart = dialogueData.Start;
const timeEnd = dialogueData.End;
const text = dialogueData.Text;

const formattedText = replaceTags(text, {
'{\\i1}(.*?){\\i0}': '<i>$1</i>', // Italics
'{\\b1}(.*?){\\b0}': '<b>$1</b>' // Bold
});

return { start: parseTime(timeStart), end: parseTime(timeEnd), text: formattedText.trim() };
};

/**
* Parses dialogue lines from SSA file.
*/
function parseSsaDialogueLines(lines: string[]): Dialogue[] {
let index = 0;
let dialogueFormat: string[] = [];
const dialogue: Dialogue[] = [];

const parseLine = (line: string, index: number): [Dialogue | undefined, number] => {
line = line.trim();

// Format fields should be defined before dialogue lines begin
if (line.startsWith('Dialogue:') && dialogueFormat.length !== 0) {
let currentDialogue = parseSsaDialogue(line, dialogueFormat);

// Handle consecutive dialogue lines with the same timestamp
[currentDialogue, index] = parseConsecutiveLines(currentDialogue, index);

return [currentDialogue, index];
} else {
return [undefined, index];
}
};

const parseConsecutiveLines = (currentDialogue: Dialogue, index: number): [Dialogue, number] => {
while (index + 1 < lines.length) {
const nextLine = lines[index + 1].trim();

if (nextLine.startsWith('Dialogue:')) {
const nextDialogue = parseSsaDialogue(nextLine, dialogueFormat);

if (nextDialogue.start === currentDialogue.start && nextDialogue.end === currentDialogue.end) {
currentDialogue.text += '\n' + nextDialogue.text;
index++;
} else {
break;
}
} else {
break;
}
}

currentDialogue.text = currentDialogue.text.replace(String.raw`\N`, '\n');

return [currentDialogue, index];
};

while (index < lines.length) {
const line = lines[index];

/**
* Parse format fields and save to a variable
* to index data from dialogue lines
*/
if (line.startsWith('Format:')) {
dialogueFormat = parseFormatFields(line);
}

/**
* Parse lines with Dialogue
* add consecutive lines at the same time together
*/
const [parsedDialogue, newIndex] = parseLine(line, index);

if (parsedDialogue) {
dialogue.push(parsedDialogue);
}

index = newIndex + 1;
}

return dialogue;
};

/**
* Parses an ASS/SSA (SubStation Alpha) file from a given URL.
* Extracts dialogue lines with start and end times, and text content.
*
* Converts specific tags to styled <span> tags
*/
export async function parseSsaFile(src: string): Promise<ParsedSubtitleTrack | undefined> {
try {
const file = await axios.get<string>(src);
const ssaText: string = file.data;

if (!ssaText) {
return;
}

const sections = ssaText.split(/\r?\n\r?\n/); // Split into sections by empty lines

let styles: Record<string, string>[] | undefined = [];
let dialogue: Dialogue[] = [];

for (const section of sections) {
if (section.startsWith('[V4 Styles]') || section.startsWith('[V4+ Styles]')) {
const lines = section.split('\n').slice(1); // Remove the [V4 Styles] line

styles = parseSsaStyles(lines);
} else if (section.startsWith('[Events]')) {
const lines = section.split('\n').slice(1); // Remove the [Events] line

dialogue = parseSsaDialogueLines(lines);
}
}

const subtitles: ParsedSubtitleTrack = {
dialogue: dialogue,
/**
* Usually an advanced substation alpha file with many effects (karaoke, anime)
* will have more than one style defined, if there's only one
* we can assume it's basic
*/
isBasic: styles.length === 1
};

return subtitles;
} catch (error) {
console.error('Error parsing SSA/ASS subtitles', error);
}
}
75 changes: 26 additions & 49 deletions frontend/src/store/player-element.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,29 +130,35 @@ class PlayerElementStore extends CommonStore<PlayerElementState> {
};
};

private readonly _setSsaTrack = async (trackSrc: string): Promise<void> => {
if (!mediaElementRef.value || !(mediaElementRef.value instanceof HTMLVideoElement)) {
return;
}

this._clear();
/**
* Applies SSA (SubStation Alpha) subtitles to the media element.
*/
private readonly _applySsaSubtitles = async (): Promise<void> => {
if (
mediaElementRef.value
&& this.currentExternalSubtitleTrack
&& (mediaElementRef.value instanceof HTMLVideoElement)
) {
this._clear();

const subtitleTrackPayload = await this._fetchSubtitleTrack(trackSrc);
const trackSrc = this.currentExternalSubtitleTrack.src;
const subtitleTrackPayload = await this._fetchSubtitleTrack(trackSrc);

if (this.currentExternalSubtitleTrack && subtitleTrackPayload[this.currentExternalSubtitleTrack.src]) {
/**
* video_width works better with ultrawide monitors
*/
this._asssub = new ASSSUB(
subtitleTrackPayload[this.currentExternalSubtitleTrack.src],
mediaElementRef.value,
{ resampling: 'video_width' }
);
if (subtitleTrackPayload[trackSrc]) {
/**
* video_width works better with ultrawide monitors
*/
this._asssub = new ASSSUB(
subtitleTrackPayload[trackSrc],
mediaElementRef.value,
{ resampling: 'video_width' }
);

this._cleanups.add(() => {
this._asssub?.destroy();
this._asssub = undefined;
});
this._cleanups.add(() => {
this._asssub?.destroy();
this._asssub = undefined;
});
}
}
};

Expand Down Expand Up @@ -205,35 +211,6 @@ class PlayerElementStore extends CommonStore<PlayerElementState> {
}
};

/**
* Applies SSA (SubStation Alpha) subtitles to the media element.
*/
private readonly _applySsaSubtitles = async (): Promise<void> => {
if (!this.currentExternalSubtitleTrack || !mediaElementRef) {
return;
}

const subtitleTrack = this.currentExternalSubtitleTrack;

/**
* Check if client is able to display custom subtitle track
*/
if (this._useCustomSubtitleTrack) {
const data = await genericWorker.parseSsaFile(subtitleTrack.src);

/**
* Check if worker returned that the sub data is 'basic', when true use basic renderer method
*/
if (data?.isBasic) {
this.currentExternalSubtitleTrack.parsed = data;

return;
}
}

this._setSsaTrack(subtitleTrack.src);
};

/**
* Applies the current subtitle from the playbackManager store
*
Expand Down

0 comments on commit 944506a

Please sign in to comment.