Просмотр исходного кода

Add embedded subtitle & font support to Movie player

Adds a `/media/subtitles/` endpoint that lists embedded subtitle tracks and font attachments from MKV containers (via ffprobe/ffmpeg), extracts tracks as SubRip or native ASS, and serves font binaries.

On the frontend, the Movie player now:
- Probes for embedded tracks on file load
- Shows muxed subtitle tracks in the subtitle context menu
- Renders ASS/SSA tracks with full styling, positioning and simultaneous events via a new in-repo `script/ass.js` parser/renderer
- Falls back to plain SubRip rendering for non-ASS tracks
- Loads embedded fonts by internal family name and registers them via FontFace API
- Exposes embedded fonts in Subtitle Settings for SubRip tracks

Also adds `fontname.go` which parses the sfnt name table to resolve a font's internal family name (needed because muxed subsets are often renamed to random strings).
Toby Chui 2 недель назад
Родитель
Сommit
8fd443842a

+ 1 - 0
src/mediaServer.go

@@ -49,6 +49,7 @@ func mediaServer_init() {
 		http.HandleFunc("/media/transcode/audio/", mediaServer.ServeAudioWithTranscode)
 		http.HandleFunc("/media/duration/", mediaServer.GetAudioDuration)
 		http.HandleFunc("/media/storyboard/", mediaServer.ServeStoryboard)
+		http.HandleFunc("/media/subtitles/", mediaServer.ServeEmbeddedSubtitles)
 	} else {
 		//ffmpeg not installed. Redirect transcode endpoint back to /media/
 		http.HandleFunc("/media/transcode/", func(w http.ResponseWriter, r *http.Request) {

+ 93 - 0
src/mod/media/mediaserver/mediaserver.go

@@ -589,6 +589,99 @@ func (s *Instance) GetAudioDuration(w http.ResponseWriter, r *http.Request) {
 	w.Write(js)
 }
 
+// ServeEmbeddedSubtitles exposes the subtitle tracks and font attachments muxed
+// inside a container (typically MKV).
+//
+//	?file=<vpath>            -> JSON listing of tracks and fonts
+//	?file=<vpath>&track=<n>  -> that subtitle track, converted to SubRip
+//	?file=<vpath>&font=<n>   -> that font attachment, as binary
+//
+// Nothing is cached: subtitle and font streams are small and extraction is
+// quick relative to the container scan, so a cache would mostly add staleness.
+func (s *Instance) ServeEmbeddedSubtitles(w http.ResponseWriter, r *http.Request) {
+	targetFsh, _, realFilepath, err := s.ValidateSourceFile(w, r)
+	if err != nil {
+		utils.SendErrorResponse(w, err.Error())
+		return
+	}
+
+	// Native check on purpose: ffmpeg has to read the container directly, and
+	// buffering a remote file in full just to read its subtitles is not worth it.
+	if targetFsh.RequireBuffer || !filesystem.FileExists(realFilepath) {
+		utils.SendErrorResponse(w, "embedded subtitles not supported for this file system")
+		return
+	}
+
+	if fontParam := r.FormValue("font"); fontParam != "" {
+		s.serveEmbeddedFont(w, realFilepath, fontParam)
+		return
+	}
+	if trackParam := r.FormValue("track"); trackParam != "" {
+		s.serveEmbeddedSubtitleTrack(w, r, realFilepath, trackParam)
+		return
+	}
+
+	info, err := transcoder.ProbeEmbeddedTracksWithFontNames(realFilepath, s.options.TmpDirectory)
+	if err != nil {
+		utils.SendErrorResponse(w, "could not read embedded tracks")
+		return
+	}
+
+	js, _ := json.Marshal(info)
+	w.Header().Set("Content-Type", "application/json")
+	w.Write(js)
+}
+
+// serveEmbeddedSubtitleTrack returns one track, either converted to SubRip or —
+// with &format=ass — copied out in its native form so the player can render the
+// original styling, positioning and layering.
+func (s *Instance) serveEmbeddedSubtitleTrack(w http.ResponseWriter, r *http.Request, realFilepath string, trackParam string) {
+	streamIndex, err := strconv.Atoi(trackParam)
+	if err != nil || streamIndex < 0 {
+		utils.SendErrorResponse(w, "invalid subtitle track index")
+		return
+	}
+
+	var payload []byte
+	if r.FormValue("format") == "ass" {
+		payload, err = transcoder.ExtractRawSubtitleTrack(realFilepath, streamIndex, "ass")
+	} else {
+		payload, err = transcoder.ExtractSubtitleTrack(realFilepath, streamIndex)
+	}
+	if err != nil {
+		s.options.Logger.PrintAndLog("Subtitle",
+			"extraction failed for "+filepath.Base(realFilepath), err)
+		utils.SendErrorResponse(w, "could not extract subtitle track")
+		return
+	}
+
+	w.Header().Set("Content-Type", "text/plain; charset=utf-8")
+	w.Header().Set("Cache-Control", "private, max-age=3600")
+	w.Write(payload)
+}
+
+// serveEmbeddedFont returns one font attachment so the player can register it
+// with @font-face and render subtitles in the typeface the release shipped.
+func (s *Instance) serveEmbeddedFont(w http.ResponseWriter, realFilepath string, fontParam string) {
+	fontIndex, err := strconv.Atoi(fontParam)
+	if err != nil || fontIndex < 0 {
+		utils.SendErrorResponse(w, "invalid font attachment index")
+		return
+	}
+
+	data, err := transcoder.ExtractFontAttachment(realFilepath, fontIndex, s.options.TmpDirectory)
+	if err != nil {
+		s.options.Logger.PrintAndLog("Subtitle",
+			"font extraction failed for "+filepath.Base(realFilepath), err)
+		utils.SendErrorResponse(w, "could not extract font attachment")
+		return
+	}
+
+	w.Header().Set("Content-Type", "font/ttf")
+	w.Header().Set("Cache-Control", "private, max-age=86400")
+	w.Write(data)
+}
+
 // storyboardLocks serialises generation per source file so that several viewers
 // opening the same video cannot each start their own ffmpeg pass.
 var storyboardLocks sync.Map

+ 136 - 0
src/mod/media/transcoder/fontname.go

@@ -0,0 +1,136 @@
+package transcoder
+
+/*
+	Fontname.go
+
+	Reads the family name out of an sfnt font (TTF/OTF) by parsing its "name"
+	table directly.
+
+	This matters because ASS styles reference fonts by their *internal* family
+	name, not by filename — and tools like assfonts deliberately rewrite that
+	internal name to a random string when muxing subsets into a container. A
+	release whose attachment is called "FOT-Pearl Std L[0]_GGORJFBL_0.ttf" may
+	well identify itself as "ECZNFERB", which is what the styles will ask for.
+	Without reading the table there is no way to connect the two.
+
+	Implemented by hand rather than pulling in a font library: the name table is
+	a simple structure and this avoids a new dependency for ~100 lines.
+*/
+
+import (
+	"encoding/binary"
+	"errors"
+	"strings"
+	"unicode/utf16"
+)
+
+const (
+	// sfnt offsets
+	sfntNumTablesOffset  = 4
+	sfntTableDirOffset   = 12
+	sfntTableRecordSize  = 16
+	nameTableHeaderSize  = 6
+	nameRecordSize       = 12
+	nameIDFontFamily     = 1
+	platformIDMacintosh  = 1
+	platformIDMicrosoft  = 3
+	maxReasonableNameLen = 512
+)
+
+// FontFamilyName extracts the family name from a TTF/OTF font.
+//
+// Microsoft platform records (UTF-16BE) are preferred because they are the ones
+// Windows-oriented tooling writes; a Macintosh record is accepted as fallback.
+func FontFamilyName(font []byte) (string, error) {
+	nameTable, err := findSfntTable(font, "name")
+	if err != nil {
+		return "", err
+	}
+	if len(nameTable) < nameTableHeaderSize {
+		return "", errors.New("name table too short")
+	}
+
+	count := int(binary.BigEndian.Uint16(nameTable[2:4]))
+	stringOffset := int(binary.BigEndian.Uint16(nameTable[4:6]))
+
+	var fallback string
+	for i := 0; i < count; i++ {
+		rec := nameTableHeaderSize + i*nameRecordSize
+		if rec+nameRecordSize > len(nameTable) {
+			break
+		}
+		platformID := binary.BigEndian.Uint16(nameTable[rec : rec+2])
+		nameID := binary.BigEndian.Uint16(nameTable[rec+6 : rec+8])
+		length := int(binary.BigEndian.Uint16(nameTable[rec+8 : rec+10]))
+		offset := int(binary.BigEndian.Uint16(nameTable[rec+10 : rec+12]))
+
+		if nameID != nameIDFontFamily || length == 0 || length > maxReasonableNameLen {
+			continue
+		}
+		start := stringOffset + offset
+		if start < 0 || start+length > len(nameTable) {
+			continue
+		}
+		raw := nameTable[start : start+length]
+
+		switch platformID {
+		case platformIDMicrosoft:
+			if name := strings.TrimSpace(decodeUTF16BE(raw)); name != "" {
+				return name, nil
+			}
+		case platformIDMacintosh:
+			if fallback == "" {
+				fallback = strings.TrimSpace(string(raw))
+			}
+		}
+	}
+
+	if fallback != "" {
+		return fallback, nil
+	}
+	return "", errors.New("no family name in font")
+}
+
+// findSfntTable locates a table by tag inside an sfnt container.
+func findSfntTable(font []byte, wantTag string) ([]byte, error) {
+	if len(font) < sfntTableDirOffset {
+		return nil, errors.New("not a font file")
+	}
+	// TrueType collections start with a different header; the attachments we
+	// care about are single fonts, so treat a collection as unsupported rather
+	// than misreading its offsets.
+	if string(font[0:4]) == "ttcf" {
+		return nil, errors.New("font collections are not supported")
+	}
+
+	numTables := int(binary.BigEndian.Uint16(font[sfntNumTablesOffset : sfntNumTablesOffset+2]))
+	for i := 0; i < numTables; i++ {
+		rec := sfntTableDirOffset + i*sfntTableRecordSize
+		if rec+sfntTableRecordSize > len(font) {
+			break
+		}
+		tag := string(font[rec : rec+4])
+		if tag != wantTag {
+			continue
+		}
+		offset := int(binary.BigEndian.Uint32(font[rec+8 : rec+12]))
+		length := int(binary.BigEndian.Uint32(font[rec+12 : rec+16]))
+		if offset < 0 || length < 0 || offset+length > len(font) {
+			return nil, errors.New("truncated font table")
+		}
+		return font[offset : offset+length], nil
+	}
+	return nil, errors.New("table not found: " + wantTag)
+}
+
+// decodeUTF16BE converts a big-endian UTF-16 name record to a Go string.
+func decodeUTF16BE(raw []byte) string {
+	if len(raw)%2 != 0 {
+		raw = raw[:len(raw)-1]
+	}
+	units := make([]uint16, 0, len(raw)/2)
+	for i := 0; i+1 < len(raw); i += 2 {
+		units = append(units, binary.BigEndian.Uint16(raw[i:i+2]))
+	}
+	return string(utf16.Decode(units))
+}

+ 210 - 0
src/mod/media/transcoder/fontname_test.go

@@ -0,0 +1,210 @@
+package transcoder
+
+import (
+	"encoding/binary"
+	"strings"
+	"testing"
+	"unicode/utf16"
+)
+
+// buildFont assembles a minimal sfnt carrying just a name table, which is all
+// FontFamilyName needs. Records are given as (platformID, nameID, value).
+type nameRec struct {
+	platformID uint16
+	nameID     uint16
+	value      string
+}
+
+func buildFont(tag string, records []nameRec) []byte {
+	// Encode the string storage and the record array
+	var storage []byte
+	recs := make([]byte, 0, len(records)*nameRecordSize)
+	for _, r := range records {
+		var encoded []byte
+		if r.platformID == platformIDMicrosoft {
+			units := utf16.Encode([]rune(r.value))
+			encoded = make([]byte, len(units)*2)
+			for i, u := range units {
+				binary.BigEndian.PutUint16(encoded[i*2:], u)
+			}
+		} else {
+			encoded = []byte(r.value)
+		}
+		rec := make([]byte, nameRecordSize)
+		binary.BigEndian.PutUint16(rec[0:2], r.platformID)
+		binary.BigEndian.PutUint16(rec[2:4], 0)
+		binary.BigEndian.PutUint16(rec[4:6], 0)
+		binary.BigEndian.PutUint16(rec[6:8], r.nameID)
+		binary.BigEndian.PutUint16(rec[8:10], uint16(len(encoded)))
+		binary.BigEndian.PutUint16(rec[10:12], uint16(len(storage)))
+		recs = append(recs, rec...)
+		storage = append(storage, encoded...)
+	}
+
+	stringOffset := nameTableHeaderSize + len(recs)
+	nameTable := make([]byte, 0, stringOffset+len(storage))
+	header := make([]byte, nameTableHeaderSize)
+	binary.BigEndian.PutUint16(header[0:2], 0)
+	binary.BigEndian.PutUint16(header[2:4], uint16(len(records)))
+	binary.BigEndian.PutUint16(header[4:6], uint16(stringOffset))
+	nameTable = append(nameTable, header...)
+	nameTable = append(nameTable, recs...)
+	nameTable = append(nameTable, storage...)
+
+	// One-table sfnt: 12-byte header, one 16-byte record, then the table
+	tableOffset := sfntTableDirOffset + sfntTableRecordSize
+	font := make([]byte, tableOffset)
+	copy(font[0:4], []byte{0x00, 0x01, 0x00, 0x00}) // TrueType version
+	binary.BigEndian.PutUint16(font[sfntNumTablesOffset:], 1)
+	copy(font[sfntTableDirOffset:], []byte(tag))
+	binary.BigEndian.PutUint32(font[sfntTableDirOffset+8:], uint32(tableOffset))
+	binary.BigEndian.PutUint32(font[sfntTableDirOffset+12:], uint32(len(nameTable)))
+	return append(font, nameTable...)
+}
+
+// TestFontFamilyName_Microsoft covers the common case: a Windows platform
+// record holding a UTF-16BE family name.
+func TestFontFamilyName_Microsoft(t *testing.T) {
+	font := buildFont("name", []nameRec{
+		{platformIDMicrosoft, nameIDFontFamily, "UUZHUQHH"},
+	})
+	got, err := FontFamilyName(font)
+	if err != nil {
+		t.Fatalf("unexpected error: %v", err)
+	}
+	if got != "UUZHUQHH" {
+		t.Errorf("expected UUZHUQHH, got %q", got)
+	}
+}
+
+// TestFontFamilyName_NonASCII verifies CJK family names survive the UTF-16
+// decode, since those are exactly what fansub releases attach.
+func TestFontFamilyName_NonASCII(t *testing.T) {
+	font := buildFont("name", []nameRec{
+		{platformIDMicrosoft, nameIDFontFamily, "方正准雅宋_GBK"},
+	})
+	got, err := FontFamilyName(font)
+	if err != nil {
+		t.Fatalf("unexpected error: %v", err)
+	}
+	if got != "方正准雅宋_GBK" {
+		t.Errorf("expected the CJK family name, got %q", got)
+	}
+}
+
+// TestFontFamilyName_PrefersMicrosoft verifies the Windows record wins when a
+// font carries both, since that is the one matching what ASS scripts reference.
+func TestFontFamilyName_PrefersMicrosoft(t *testing.T) {
+	font := buildFont("name", []nameRec{
+		{platformIDMacintosh, nameIDFontFamily, "MacName"},
+		{platformIDMicrosoft, nameIDFontFamily, "WindowsName"},
+	})
+	got, err := FontFamilyName(font)
+	if err != nil {
+		t.Fatalf("unexpected error: %v", err)
+	}
+	if got != "WindowsName" {
+		t.Errorf("expected WindowsName, got %q", got)
+	}
+}
+
+// TestFontFamilyName_MacintoshFallback verifies a font with only a Macintosh
+// record still resolves.
+func TestFontFamilyName_MacintoshFallback(t *testing.T) {
+	font := buildFont("name", []nameRec{
+		{platformIDMacintosh, nameIDFontFamily, "OnlyMac"},
+	})
+	got, err := FontFamilyName(font)
+	if err != nil {
+		t.Fatalf("unexpected error: %v", err)
+	}
+	if got != "OnlyMac" {
+		t.Errorf("expected OnlyMac, got %q", got)
+	}
+}
+
+// TestFontFamilyName_IgnoresOtherNameIDs verifies only the family record (ID 1)
+// is used, not the style or full-name records.
+func TestFontFamilyName_IgnoresOtherNameIDs(t *testing.T) {
+	font := buildFont("name", []nameRec{
+		{platformIDMicrosoft, 2, "Bold"},
+		{platformIDMicrosoft, 4, "Family Bold"},
+		{platformIDMicrosoft, nameIDFontFamily, "Family"},
+	})
+	got, err := FontFamilyName(font)
+	if err != nil {
+		t.Fatalf("unexpected error: %v", err)
+	}
+	if got != "Family" {
+		t.Errorf("expected Family, got %q", got)
+	}
+}
+
+// TestFontFamilyName_Rejects covers the malformed inputs the parser must not
+// panic on, since attachment bytes come straight from an untrusted container.
+func TestFontFamilyName_Rejects(t *testing.T) {
+	cases := []struct {
+		name string
+		data []byte
+	}{
+		{"empty", []byte{}},
+		{"too short for a header", []byte{0, 1, 0, 0, 0}},
+		{"no name table", buildFont("glyf", []nameRec{{platformIDMicrosoft, nameIDFontFamily, "X"}})},
+		{"no family record", buildFont("name", []nameRec{{platformIDMicrosoft, 4, "Full Name Only"}})},
+		{"font collection", append([]byte("ttcf"), make([]byte, 32)...)},
+		{"random bytes", []byte("this is definitely not a font at all")},
+	}
+
+	for _, tc := range cases {
+		t.Run(tc.name, func(t *testing.T) {
+			if _, err := FontFamilyName(tc.data); err == nil {
+				t.Error("expected an error, got nil")
+			}
+		})
+	}
+}
+
+// TestFontFamilyName_TruncatedTableOffset verifies a table directory pointing
+// past the end of the data is rejected rather than slicing out of range.
+func TestFontFamilyName_TruncatedTableOffset(t *testing.T) {
+	font := buildFont("name", []nameRec{{platformIDMicrosoft, nameIDFontFamily, "X"}})
+	// Point the name table beyond the buffer
+	binary.BigEndian.PutUint32(font[sfntTableDirOffset+8:], uint32(len(font)+500))
+	if _, err := FontFamilyName(font); err == nil {
+		t.Error("expected an error for a truncated table, got nil")
+	}
+}
+
+// TestDecodeUTF16BE checks the decoder handles an odd trailing byte instead of
+// reading past the slice.
+func TestDecodeUTF16BE(t *testing.T) {
+	units := utf16.Encode([]rune("Hi"))
+	raw := make([]byte, len(units)*2)
+	for i, u := range units {
+		binary.BigEndian.PutUint16(raw[i*2:], u)
+	}
+	if got := decodeUTF16BE(raw); got != "Hi" {
+		t.Errorf("expected Hi, got %q", got)
+	}
+	if got := decodeUTF16BE(append(raw, 0x00)); got != "Hi" {
+		t.Errorf("expected Hi from odd-length input, got %q", got)
+	}
+	if got := decodeUTF16BE(nil); got != "" {
+		t.Errorf("expected empty string, got %q", got)
+	}
+}
+
+// TestFontFamilyName_TrimsWhitespace verifies padded names are cleaned up, as
+// some tools pad the record.
+func TestFontFamilyName_TrimsWhitespace(t *testing.T) {
+	font := buildFont("name", []nameRec{
+		{platformIDMicrosoft, nameIDFontFamily, "  Padded Name  "},
+	})
+	got, err := FontFamilyName(font)
+	if err != nil {
+		t.Fatalf("unexpected error: %v", err)
+	}
+	if got != "Padded Name" || strings.HasPrefix(got, " ") {
+		t.Errorf("expected trimmed name, got %q", got)
+	}
+}

+ 419 - 0
src/mod/media/transcoder/subtitles.go

@@ -0,0 +1,419 @@
+package transcoder
+
+/*
+	Subtitles.go
+
+	Discovery and extraction of subtitle tracks and font attachments that are
+	muxed inside a container (typically MKV).
+
+	Text tracks are converted to SubRip on the way out so the player has a single
+	format to parse. Note that converting ASS/SSA discards styling, positioning
+	and karaoke timing — only the dialogue text and its timings survive. Font
+	attachments are exposed separately so the player can still render that text in
+	the typeface the release intended.
+*/
+
+import (
+	"bytes"
+	"context"
+	"encoding/json"
+	"errors"
+	"fmt"
+	"os"
+	"os/exec"
+	"path/filepath"
+	"strings"
+	"time"
+)
+
+const (
+	// Probing and extraction are bounded so a damaged container cannot hang a
+	// request forever.
+	subtitleProbeTimeout   = 30 * time.Second
+	subtitleExtractTimeout = 5 * time.Minute
+	// Guards against a malicious or broken file advertising an enormous track.
+	maxSubtitleBytes = 32 << 20 // 32 MiB
+	maxFontBytes     = 32 << 20 // 32 MiB
+)
+
+// EmbeddedSubtitle describes one subtitle track found inside a container.
+type EmbeddedSubtitle struct {
+	Index    int    `json:"index"`    // absolute stream index, used for -map
+	Codec    string `json:"codec"`    // subrip, ass, hdmv_pgs_subtitle, …
+	Language string `json:"language"` // ISO code from the container, may be empty
+	Title    string `json:"title"`    // human label from the container, may be empty
+	Default  bool   `json:"default"`
+	Forced   bool   `json:"forced"`
+	Textual  bool   `json:"textual"` // false for bitmap tracks, which cannot be converted
+}
+
+// EmbeddedFont describes one font attachment found inside a container.
+type EmbeddedFont struct {
+	Index    int    `json:"index"`    // ordinal among attachment streams, used for -dump_attachment
+	Filename string `json:"filename"` // original name, e.g. "FOT-Pearl Std L.ttf"
+	Mimetype string `json:"mimetype"` // font/ttf, application/x-truetype-font, …
+	Family   string `json:"family"`   // internal family name from the font's name table
+}
+
+// MediaSubtitleInfo is the full picture of what a container carries.
+type MediaSubtitleInfo struct {
+	Subtitles []EmbeddedSubtitle `json:"subtitles"`
+	Fonts     []EmbeddedFont     `json:"fonts"`
+}
+
+// textualSubtitleCodecs are the codecs ffmpeg can turn into SubRip. Everything
+// else (PGS, VobSub, …) is a bitmap format that would need OCR.
+var textualSubtitleCodecs = map[string]bool{
+	"subrip":   true,
+	"srt":      true,
+	"ass":      true,
+	"ssa":      true,
+	"webvtt":   true,
+	"mov_text": true,
+	"text":     true,
+	"microdvd": true,
+}
+
+// fontMimeHints are the attachment mimetypes that identify a font.
+var fontMimeHints = []string{"font", "truetype", "opentype", "sfnt"}
+
+// fontFileExtensions are the fallback signal when a container omits a mimetype.
+var fontFileExtensions = map[string]bool{
+	".ttf": true, ".otf": true, ".ttc": true, ".woff": true, ".woff2": true,
+}
+
+// IsTextualSubtitleCodec reports whether a subtitle codec carries text that can
+// be converted to SubRip, as opposed to a bitmap format needing OCR.
+func IsTextualSubtitleCodec(codec string) bool {
+	return textualSubtitleCodecs[strings.ToLower(strings.TrimSpace(codec))]
+}
+
+// IsFontAttachment reports whether an attachment stream looks like a font,
+// judged by mimetype first and filename extension second.
+func IsFontAttachment(mimetype string, filename string) bool {
+	lowerMime := strings.ToLower(mimetype)
+	for _, hint := range fontMimeHints {
+		if strings.Contains(lowerMime, hint) {
+			return true
+		}
+	}
+	return fontFileExtensions[strings.ToLower(filepath.Ext(filename))]
+}
+
+// ffprobeStream is the subset of ffprobe's stream output we care about.
+type ffprobeStream struct {
+	Index       int               `json:"index"`
+	CodecName   string            `json:"codec_name"`
+	CodecType   string            `json:"codec_type"`
+	Tags        map[string]string `json:"tags"`
+	Disposition map[string]int    `json:"disposition"`
+}
+
+// tag reads a container tag case-insensitively, since muxers disagree on case.
+func (s *ffprobeStream) tag(name string) string {
+	for k, v := range s.Tags {
+		if strings.EqualFold(k, name) {
+			return v
+		}
+	}
+	return ""
+}
+
+// ProbeEmbeddedTracksWithFontNames lists embedded tracks and additionally reads
+// each font attachment's internal family name.
+//
+// ASS styles reference fonts by that internal name, so the player cannot match
+// a style to an attachment without it. Every attachment is dumped in a single
+// ffmpeg call, which costs about the same as dumping one (measured at ~65ms for
+// 14 fonts) because attachments are written while the input is opened.
+//
+// Name resolution is best effort: a font that cannot be parsed simply keeps an
+// empty Family and the caller falls back to the filename.
+func ProbeEmbeddedTracksWithFontNames(inputFile string, workDir string) (*MediaSubtitleInfo, error) {
+	info, err := ProbeEmbeddedTracks(inputFile)
+	if err != nil {
+		return nil, err
+	}
+	if len(info.Fonts) == 0 {
+		return info, nil
+	}
+
+	fonts, err := extractAllFontAttachments(inputFile, info.Fonts, workDir)
+	if err != nil {
+		return info, nil // listing is still useful without family names
+	}
+	for i := range info.Fonts {
+		data, ok := fonts[info.Fonts[i].Index]
+		if !ok {
+			continue
+		}
+		if family, err := FontFamilyName(data); err == nil {
+			info.Fonts[i].Family = family
+		}
+	}
+	return info, nil
+}
+
+// extractAllFontAttachments dumps every listed attachment in one ffmpeg pass and
+// returns the bytes keyed by attachment ordinal.
+func extractAllFontAttachments(inputFile string, fonts []EmbeddedFont, workDir string) (map[int][]byte, error) {
+	if strings.TrimSpace(workDir) == "" {
+		workDir = os.TempDir()
+	}
+	scratchDir, err := os.MkdirTemp(workDir, "subfonts-")
+	if err != nil {
+		return nil, fmt.Errorf("scratch directory unavailable: %w", err)
+	}
+	defer os.RemoveAll(scratchDir)
+
+	args := []string{"-y", "-v", "error"}
+	paths := map[int]string{}
+	for _, font := range fonts {
+		path := filepath.Join(scratchDir, fmt.Sprintf("%d.bin", font.Index))
+		paths[font.Index] = path
+		args = append(args, fmt.Sprintf("-dump_attachment:t:%d", font.Index), path)
+	}
+	// As in ExtractFontAttachment, no output is given on purpose: the dump
+	// completes while the input is opened, and adding one would make ffmpeg
+	// process the entire video first.
+	args = append(args, "-i", inputFile)
+
+	ctx, cancel := context.WithTimeout(context.Background(), subtitleExtractTimeout)
+	defer cancel()
+	exec.CommandContext(ctx, "ffmpeg", args...).Run() // exit status is not the signal
+
+	out := map[int][]byte{}
+	for index, path := range paths {
+		data, err := os.ReadFile(path)
+		if err == nil && len(data) > 0 && len(data) <= maxFontBytes {
+			out[index] = data
+		}
+	}
+	if len(out) == 0 {
+		return nil, errors.New("no attachments could be read")
+	}
+	return out, nil
+}
+
+// ExtractRawSubtitleTrack copies a subtitle track out in its native format,
+// preserving ASS styling, positioning and layering.
+//
+// This is a stream copy rather than a re-encode, so it is dramatically cheaper
+// than converting to SubRip — measured at 0.24s versus 5.3s on a 614MB file.
+func ExtractRawSubtitleTrack(inputFile string, streamIndex int, format string) ([]byte, error) {
+	if streamIndex < 0 {
+		return nil, errors.New("invalid subtitle stream index")
+	}
+	if format != "ass" && format != "srt" && format != "webvtt" {
+		return nil, errors.New("unsupported raw subtitle format")
+	}
+
+	ctx, cancel := context.WithTimeout(context.Background(), subtitleExtractTimeout)
+	defer cancel()
+
+	cmd := exec.CommandContext(ctx, "ffmpeg",
+		"-v", "error",
+		"-i", inputFile,
+		"-map", fmt.Sprintf("0:%d", streamIndex),
+		"-vn", "-an",
+		"-c:s", "copy",
+		"-f", format,
+		"pipe:1",
+	)
+
+	var stdout, stderr bytes.Buffer
+	cmd.Stdout = &stdout
+	cmd.Stderr = &stderr
+
+	if err := cmd.Run(); err != nil {
+		if ctx.Err() == context.DeadlineExceeded {
+			return nil, errors.New("subtitle extraction timed out")
+		}
+		return nil, fmt.Errorf("ffmpeg failed: %w (%s)", err, lastLines(stderr.String(), 2))
+	}
+	if stdout.Len() == 0 {
+		return nil, errors.New("subtitle track is empty")
+	}
+	if stdout.Len() > maxSubtitleBytes {
+		return nil, errors.New("subtitle track is unexpectedly large")
+	}
+	return stdout.Bytes(), nil
+}
+
+// ProbeEmbeddedTracks lists the subtitle tracks and font attachments inside a
+// container. A file with neither returns empty slices rather than an error.
+func ProbeEmbeddedTracks(inputFile string) (*MediaSubtitleInfo, error) {
+	ctx, cancel := context.WithTimeout(context.Background(), subtitleProbeTimeout)
+	defer cancel()
+
+	cmd := exec.CommandContext(ctx, "ffprobe",
+		"-v", "quiet",
+		"-print_format", "json",
+		"-show_streams",
+		inputFile,
+	)
+	output, err := cmd.Output()
+	if err != nil {
+		if ctx.Err() == context.DeadlineExceeded {
+			return nil, errors.New("subtitle probe timed out")
+		}
+		return nil, fmt.Errorf("ffprobe failed: %w", err)
+	}
+
+	return parseEmbeddedTracks(output)
+}
+
+// parseEmbeddedTracks turns ffprobe JSON into the track listing. Split out from
+// the exec call so the mapping rules can be unit-tested without ffmpeg.
+func parseEmbeddedTracks(probeJSON []byte) (*MediaSubtitleInfo, error) {
+	var parsed struct {
+		Streams []ffprobeStream `json:"streams"`
+	}
+	if err := json.Unmarshal(probeJSON, &parsed); err != nil {
+		return nil, fmt.Errorf("could not parse ffprobe output: %w", err)
+	}
+
+	info := &MediaSubtitleInfo{
+		Subtitles: []EmbeddedSubtitle{},
+		Fonts:     []EmbeddedFont{},
+	}
+
+	attachmentOrdinal := 0
+	for i := range parsed.Streams {
+		stream := &parsed.Streams[i]
+		switch strings.ToLower(stream.CodecType) {
+		case "subtitle":
+			info.Subtitles = append(info.Subtitles, EmbeddedSubtitle{
+				Index:    stream.Index,
+				Codec:    stream.CodecName,
+				Language: stream.tag("language"),
+				Title:    stream.tag("title"),
+				Default:  stream.Disposition["default"] == 1,
+				Forced:   stream.Disposition["forced"] == 1,
+				Textual:  IsTextualSubtitleCodec(stream.CodecName),
+			})
+		case "attachment":
+			filename := stream.tag("filename")
+			mimetype := stream.tag("mimetype")
+			// The ordinal counts every attachment, font or not, because that is
+			// what ffmpeg's -dump_attachment:t:<n> specifier indexes on.
+			current := attachmentOrdinal
+			attachmentOrdinal++
+			if !IsFontAttachment(mimetype, filename) {
+				continue
+			}
+			info.Fonts = append(info.Fonts, EmbeddedFont{
+				Index:    current,
+				Filename: filename,
+				Mimetype: mimetype,
+			})
+		}
+	}
+
+	return info, nil
+}
+
+// ExtractSubtitleTrack pulls one subtitle track out of a container and returns
+// it as SubRip text.
+//
+// streamIndex is the absolute stream index reported by ProbeEmbeddedTracks. The
+// conversion flattens ASS/SSA styling to plain text; callers wanting full
+// styling would need to extract the native format and render it with libass.
+func ExtractSubtitleTrack(inputFile string, streamIndex int) ([]byte, error) {
+	if streamIndex < 0 {
+		return nil, errors.New("invalid subtitle stream index")
+	}
+
+	ctx, cancel := context.WithTimeout(context.Background(), subtitleExtractTimeout)
+	defer cancel()
+
+	cmd := exec.CommandContext(ctx, "ffmpeg",
+		"-v", "error",
+		"-i", inputFile,
+		"-map", fmt.Sprintf("0:%d", streamIndex),
+		"-vn", "-an", // subtitle stream only
+		"-c:s", "srt",
+		"-f", "srt",
+		"pipe:1",
+	)
+
+	var stdout, stderr bytes.Buffer
+	cmd.Stdout = &stdout
+	cmd.Stderr = &stderr
+
+	if err := cmd.Run(); err != nil {
+		if ctx.Err() == context.DeadlineExceeded {
+			return nil, errors.New("subtitle extraction timed out")
+		}
+		return nil, fmt.Errorf("ffmpeg failed: %w (%s)", err, lastLines(stderr.String(), 2))
+	}
+
+	if stdout.Len() == 0 {
+		return nil, errors.New("subtitle track is empty")
+	}
+	if stdout.Len() > maxSubtitleBytes {
+		return nil, errors.New("subtitle track is unexpectedly large")
+	}
+	return stdout.Bytes(), nil
+}
+
+// ExtractFontAttachment pulls one font attachment out of a container.
+//
+// fontIndex is the attachment ordinal reported by ProbeEmbeddedTracks. ffmpeg
+// can only dump attachments to a real path, so this renders into scratch space
+// under workDir and returns the bytes for the caller to serve or store.
+func ExtractFontAttachment(inputFile string, fontIndex int, workDir string) ([]byte, error) {
+	if fontIndex < 0 {
+		return nil, errors.New("invalid font attachment index")
+	}
+
+	if strings.TrimSpace(workDir) == "" {
+		workDir = os.TempDir()
+	}
+	if err := os.MkdirAll(workDir, 0775); err != nil {
+		return nil, fmt.Errorf("scratch directory unavailable: %w", err)
+	}
+
+	scratch, err := os.CreateTemp(workDir, "subfont-*.bin")
+	if err != nil {
+		return nil, fmt.Errorf("could not create scratch file: %w", err)
+	}
+	scratchPath := scratch.Name()
+	scratch.Close()
+	defer os.Remove(scratchPath)
+
+	ctx, cancel := context.WithTimeout(context.Background(), subtitleExtractTimeout)
+	defer cancel()
+
+	// -dump_attachment is an input option, so it has to precede -i.
+	//
+	// No output is specified on purpose. Attachments are written while ffmpeg
+	// opens the input, so the dump is already complete by the time it complains
+	// that no output file was given and exits non-zero. Adding "-f null -" to
+	// silence that error would make ffmpeg process every video and audio packet
+	// in the container first: measured at 74s versus 0.06s on a 614MB HEVC file.
+	cmd := exec.CommandContext(ctx, "ffmpeg",
+		"-y",
+		"-v", "error",
+		fmt.Sprintf("-dump_attachment:t:%d", fontIndex), scratchPath,
+		"-i", inputFile,
+	)
+
+	// Hence the written file, not the exit status, is the success signal.
+	out, runErr := cmd.CombinedOutput()
+
+	data, err := os.ReadFile(scratchPath)
+	if err != nil || len(data) == 0 {
+		if ctx.Err() == context.DeadlineExceeded {
+			return nil, errors.New("font extraction timed out")
+		}
+		if runErr != nil {
+			return nil, fmt.Errorf("ffmpeg failed: %w (%s)", runErr, lastLines(string(out), 2))
+		}
+		return nil, errors.New("font attachment is empty")
+	}
+	if len(data) > maxFontBytes {
+		return nil, errors.New("font attachment is unexpectedly large")
+	}
+	return data, nil
+}

+ 238 - 0
src/mod/media/transcoder/subtitles_test.go

@@ -0,0 +1,238 @@
+package transcoder
+
+import (
+	"os"
+	"testing"
+)
+
+// readDirNames lists the plain file names in a directory, used to assert that
+// scratch files are cleaned up.
+func readDirNames(dir string) ([]string, error) {
+	entries, err := os.ReadDir(dir)
+	if err != nil {
+		return nil, err
+	}
+	names := []string{}
+	for _, e := range entries {
+		if !e.IsDir() {
+			names = append(names, e.Name())
+		}
+	}
+	return names, nil
+}
+
+// TestIsTextualSubtitleCodec verifies text formats are accepted for SubRip
+// conversion while bitmap formats, which would need OCR, are rejected.
+func TestIsTextualSubtitleCodec(t *testing.T) {
+	cases := []struct {
+		codec string
+		want  bool
+	}{
+		{"subrip", true},
+		{"ass", true},
+		{"ssa", true},
+		{"webvtt", true},
+		{"mov_text", true},
+		{"ASS", true},        // muxers disagree on case
+		{"  subrip  ", true}, // and on padding
+		{"hdmv_pgs_subtitle", false},
+		{"dvd_subtitle", false},
+		{"dvb_subtitle", false},
+		{"xsub", false},
+		{"", false},
+	}
+
+	for _, tc := range cases {
+		t.Run(tc.codec, func(t *testing.T) {
+			if got := IsTextualSubtitleCodec(tc.codec); got != tc.want {
+				t.Errorf("codec %q: expected %v, got %v", tc.codec, tc.want, got)
+			}
+		})
+	}
+}
+
+// TestIsFontAttachment verifies fonts are detected by mimetype, falling back to
+// the filename extension when a container omits the mimetype.
+func TestIsFontAttachment(t *testing.T) {
+	cases := []struct {
+		name     string
+		mimetype string
+		filename string
+		want     bool
+	}{
+		{"ttf mimetype", "font/ttf", "A.ttf", true},
+		{"legacy truetype mimetype", "application/x-truetype-font", "B.ttf", true},
+		{"opentype mimetype", "application/vnd.ms-opentype", "C.otf", true},
+		{"sfnt mimetype", "application/font-sfnt", "D.ttf", true},
+		{"extension only", "", "FOT-Pearl Std L.ttf", true},
+		{"otf extension only", "", "汉仪旗黑.otf", true},
+		{"uppercase extension", "", "FONT.TTF", true},
+		{"cover image", "image/jpeg", "cover.jpg", false},
+		{"unknown binary", "application/octet-stream", "notes.txt", false},
+		{"nothing to go on", "", "", false},
+	}
+
+	for _, tc := range cases {
+		t.Run(tc.name, func(t *testing.T) {
+			if got := IsFontAttachment(tc.mimetype, tc.filename); got != tc.want {
+				t.Errorf("expected %v, got %v", tc.want, got)
+			}
+		})
+	}
+}
+
+// TestParseEmbeddedTracks maps a realistic ffprobe payload — modelled on a
+// fansubbed MKV with two subtitle tracks, a cover image and font attachments.
+func TestParseEmbeddedTracks(t *testing.T) {
+	probe := []byte(`{"streams":[
+		{"index":0,"codec_name":"hevc","codec_type":"video"},
+		{"index":1,"codec_name":"aac","codec_type":"audio","tags":{"language":"jpn"}},
+		{"index":2,"codec_name":"ass","codec_type":"subtitle",
+		 "tags":{"language":"chi","title":"cht&jpn[XKsub]"},
+		 "disposition":{"default":1,"forced":0}},
+		{"index":3,"codec_name":"hdmv_pgs_subtitle","codec_type":"subtitle",
+		 "tags":{"language":"eng"},"disposition":{"default":0,"forced":1}},
+		{"index":4,"codec_name":"mjpeg","codec_type":"attachment",
+		 "tags":{"filename":"cover.jpg","mimetype":"image/jpeg"}},
+		{"index":5,"codec_name":"ttf","codec_type":"attachment",
+		 "tags":{"filename":"FOT-Pearl Std L.ttf","mimetype":"font/ttf"}},
+		{"index":6,"codec_name":"ttf","codec_type":"attachment",
+		 "tags":{"filename":"HYZhengYuan-55S.ttf","mimetype":"font/ttf"}}
+	]}`)
+
+	info, err := parseEmbeddedTracks(probe)
+	if err != nil {
+		t.Fatalf("unexpected error: %v", err)
+	}
+
+	if len(info.Subtitles) != 2 {
+		t.Fatalf("expected 2 subtitle tracks, got %d", len(info.Subtitles))
+	}
+
+	ass := info.Subtitles[0]
+	if ass.Index != 2 || ass.Codec != "ass" || ass.Language != "chi" {
+		t.Errorf("unexpected first track: %+v", ass)
+	}
+	if ass.Title != "cht&jpn[XKsub]" {
+		t.Errorf("expected the container title to survive, got %q", ass.Title)
+	}
+	if !ass.Default || ass.Forced || !ass.Textual {
+		t.Errorf("expected a default, non-forced, textual track: %+v", ass)
+	}
+
+	pgs := info.Subtitles[1]
+	if pgs.Textual {
+		t.Error("a PGS bitmap track must not be reported as textual")
+	}
+	if !pgs.Forced {
+		t.Error("expected the forced disposition to be carried through")
+	}
+
+	// The cover image must be skipped, but it still consumes an attachment
+	// ordinal — ffmpeg's -dump_attachment:t:<n> counts every attachment.
+	if len(info.Fonts) != 2 {
+		t.Fatalf("expected 2 fonts, got %d", len(info.Fonts))
+	}
+	if info.Fonts[0].Index != 1 || info.Fonts[0].Filename != "FOT-Pearl Std L.ttf" {
+		t.Errorf("unexpected first font: %+v", info.Fonts[0])
+	}
+	if info.Fonts[1].Index != 2 {
+		t.Errorf("expected the second font at attachment ordinal 2, got %d", info.Fonts[1].Index)
+	}
+}
+
+// TestParseEmbeddedTracks_NoTracks verifies a plain file yields empty slices
+// rather than an error or nil, so the JSON response stays well formed.
+func TestParseEmbeddedTracks_NoTracks(t *testing.T) {
+	info, err := parseEmbeddedTracks([]byte(`{"streams":[
+		{"index":0,"codec_name":"h264","codec_type":"video"},
+		{"index":1,"codec_name":"aac","codec_type":"audio"}
+	]}`))
+	if err != nil {
+		t.Fatalf("unexpected error: %v", err)
+	}
+	if info.Subtitles == nil || len(info.Subtitles) != 0 {
+		t.Errorf("expected an empty subtitle slice, got %#v", info.Subtitles)
+	}
+	if info.Fonts == nil || len(info.Fonts) != 0 {
+		t.Errorf("expected an empty font slice, got %#v", info.Fonts)
+	}
+}
+
+// TestParseEmbeddedTracks_CaseInsensitiveTags verifies tag lookup tolerates the
+// casing differences between muxers.
+func TestParseEmbeddedTracks_CaseInsensitiveTags(t *testing.T) {
+	info, err := parseEmbeddedTracks([]byte(`{"streams":[
+		{"index":0,"codec_name":"subrip","codec_type":"subtitle",
+		 "tags":{"LANGUAGE":"eng","Title":"English"}},
+		{"index":1,"codec_name":"ttf","codec_type":"attachment",
+		 "tags":{"FileName":"X.ttf","MIMEType":"font/ttf"}}
+	]}`))
+	if err != nil {
+		t.Fatalf("unexpected error: %v", err)
+	}
+	if len(info.Subtitles) != 1 || info.Subtitles[0].Language != "eng" || info.Subtitles[0].Title != "English" {
+		t.Errorf("expected case-insensitive subtitle tags, got %+v", info.Subtitles)
+	}
+	if len(info.Fonts) != 1 || info.Fonts[0].Filename != "X.ttf" {
+		t.Errorf("expected case-insensitive attachment tags, got %+v", info.Fonts)
+	}
+}
+
+// TestParseEmbeddedTracks_MissingDisposition verifies streams without a
+// disposition block do not panic and default to false.
+func TestParseEmbeddedTracks_MissingDisposition(t *testing.T) {
+	info, err := parseEmbeddedTracks([]byte(`{"streams":[
+		{"index":0,"codec_name":"subrip","codec_type":"subtitle"}
+	]}`))
+	if err != nil {
+		t.Fatalf("unexpected error: %v", err)
+	}
+	if len(info.Subtitles) != 1 {
+		t.Fatalf("expected 1 subtitle track, got %d", len(info.Subtitles))
+	}
+	if info.Subtitles[0].Default || info.Subtitles[0].Forced {
+		t.Error("expected dispositions to default to false")
+	}
+}
+
+// TestParseEmbeddedTracks_InvalidJSON verifies malformed probe output surfaces
+// as an error rather than an empty listing.
+func TestParseEmbeddedTracks_InvalidJSON(t *testing.T) {
+	if _, err := parseEmbeddedTracks([]byte(`not json`)); err == nil {
+		t.Error("expected an error for malformed ffprobe output, got nil")
+	}
+}
+
+// TestExtractSubtitleTrack_NegativeIndex verifies the guard runs before ffmpeg
+// is invoked, so the call is safe on hosts without it.
+func TestExtractSubtitleTrack_NegativeIndex(t *testing.T) {
+	if _, err := ExtractSubtitleTrack("nonexistent.mkv", -1); err == nil {
+		t.Error("expected an error for a negative stream index, got nil")
+	}
+}
+
+// TestExtractFontAttachment_NegativeIndex verifies the same guard for fonts.
+func TestExtractFontAttachment_NegativeIndex(t *testing.T) {
+	if _, err := ExtractFontAttachment("nonexistent.mkv", -1, t.TempDir()); err == nil {
+		t.Error("expected an error for a negative attachment index, got nil")
+	}
+}
+
+// TestExtractFontAttachment_LeavesNoScratchFiles verifies the scratch file used
+// to bridge ffmpeg's native-only dump is cleaned up even when extraction fails.
+func TestExtractFontAttachment_LeavesNoScratchFiles(t *testing.T) {
+	workDir := t.TempDir()
+
+	if _, err := ExtractFontAttachment("nonexistent.mkv", 0, workDir); err == nil {
+		t.Skip("ffmpeg unexpectedly succeeded on a missing input")
+	}
+
+	entries, err := readDirNames(workDir)
+	if err != nil {
+		t.Fatalf("could not inspect scratch dir: %v", err)
+	}
+	for _, name := range entries {
+		t.Errorf("scratch file %q was left behind", name)
+	}
+}

+ 1 - 0
src/web/Movie/backend/common.js

@@ -12,6 +12,7 @@ var BACKEND_PATH  = APP_NAME + "/backend/";
 var MEDIA_API     = "../media";               // ?file=<vpath>  streams a file
 var TRANSCODE_API  = "../media/transcode";            // ?file
 var STORYBOARD_API = "../media/storyboard/";          // ?file[&image=1]  scrub previews
+var SUBTITLE_API   = "../media/subtitles/";           // ?file[&track=n|&font=n]  embedded tracks
 var AGI_INTERFACE = "../system/ajgi/interface?script=";
 
 // ── Script paths (used when calling ao_module_agirun from the frontend) ──────

+ 357 - 8
src/web/Movie/embedded.html

@@ -8,6 +8,9 @@
     <script src="../script/jquery.min.js"></script>
     <script src="../script/ao_module.js"></script>
     <script src="backend/common.js"></script>
+
+    <!-- ASS/SSA subtitle parser + renderer (see script/ass.js) -->
+    <script src="script/ass.js"></script>
     <style>
         *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
 
@@ -102,6 +105,23 @@
         }
         #spacer { flex: 1; }
 
+        /* ─── ASS subtitle overlay ───────────────────────────────────────────────────── */
+        /* Sized in JS to the video's displayed rect so script coordinates land correctly
+           regardless of letterboxing. Children are absolutely positioned by the renderer. */
+        #ass-overlay {
+            display: none;
+            position: absolute;
+            left: 0; top: 0; width: 0; height: 0;
+            pointer-events: none;
+            overflow: hidden;
+            z-index: 14;
+            /* libass spaces lines by the font's own ascent+descent with no extra
+               leading; 1.2 would push stacked dual-language lines apart and break
+               the MarginV offsets releases use to sit them one above the other. */
+            line-height: 1.05;
+        }
+        #ass-overlay.active { display: block; }
+
         /* ── Scrub-bar hover preview ───────────────────────────────────────── */
         #scrub-preview {
             display: none;
@@ -318,6 +338,7 @@
             flex: 1; background: var(--surface2); color: var(--text);
             border: 1px solid rgba(255,255,255,0.08); border-radius: calc(var(--radius) / 1.6);
             padding: 5px 8px; font-size: 12px; cursor: pointer; outline: none; appearance: auto;
+            width: 100%;
         }
         .sset-colors { display: flex; align-items: center; gap: 7px; flex-wrap: wrap; flex: 1; }
         .sset-color {
@@ -466,6 +487,11 @@
         </div>
     </div>
 
+    <!-- Styled ASS/SSA subtitles are drawn here, sized to the video's
+         displayed rectangle. The plain #subtitle-display below is used
+         for SubRip tracks. -->
+    <div id="ass-overlay"></div>
+
     <!-- Subtitle overlay -->
     <div id="subtitle-display"></div>
 
@@ -1345,6 +1371,7 @@ function initSubtitleSettings() {
     });
     $('#sset-font').on('change', function () {
         subtitleSettings.font = $(this).val();
+        ensureEmbeddedFontLoaded(subtitleSettings.font);   // no-op for built-in fonts
         applySubtitleSettings();
         syncSettingsPreview();
         saveSubtitleSettings();
@@ -1393,6 +1420,7 @@ window.movieEmbOnSubtitleFile = function (raw) {
                 activeSubtitleIndex = loadedSubtitleFiles.length - 1;
             }
             showToast('Subtitle loaded: ' + name);
+            applyActiveSubtitle();   // a sidecar file replaces any styled track
             updateSubtitleSubmenu();
         },
         error: function () { showToast('Failed to load subtitle file'); }
@@ -1419,13 +1447,22 @@ function parseSrt(content) {
         if (parts.length < 2) { return; }
         var start = parseSrtTime(parts[0].trim());
         var end   = parseSrtTime(parts[1].trim().split(' ')[0]);
-        var text  = lines.slice(tcIdx + 1).join('\n').trim().replace(/<[^>]+>/g, '');
+        // Strip inline SRT tags like <b>, <i>, <font>, plus any ASS override
+        // blocks such as {\an8} or {\pos(x,y)}. ffmpeg's ASS-to-SubRip
+        // conversion leaves positioning tags like those behind, so without this
+        // they would be shown to the viewer as literal text.
+        var text  = lines.slice(tcIdx + 1).join('\n').trim()
+                        .replace(/<[^>]+>/g, '')
+                        .replace(/\{\\[^}]*\}/g, '')
+                        .trim();
         if (text) { cues.push({ start: start, end: end, text: text }); }
     });
     return cues;
 }
 
 function updateSubtitleDisplay() {
+    // Styled tracks are drawn by the ASS renderer on its own frame loop
+    if (assActive) { $('#subtitle-display').hide(); return; }
     if (activeSubtitleIndex < 0 || !loadedSubtitleFiles[activeSubtitleIndex]) {
         $('#subtitle-display').hide();
         return;
@@ -1445,6 +1482,284 @@ function updateSubtitleDisplay() {
     }
 }
 
+// ── Embedded subtitle tracks and fonts ────────────────────────────────────────
+// MKV releases usually mux their subtitles (and the fonts they were typeset in)
+// into the container rather than shipping sidecar files. The server lists them
+// and extracts on demand; tracks are converted to SubRip, which keeps the text
+// and timing but drops ASS styling, positioning and karaoke.
+var embeddedSubtitles   = [];     // [{index, codec, language, title, forced, textual}]
+var embeddedFonts       = [];     // [{index, filename, mimetype}]
+var embeddedMediaPath   = null;   // file the two lists above describe
+var embeddedProbeToken  = 0;      // invalidates in-flight probes on file change
+var embeddedFontFaces   = [];     // FontFace objects registered for this video
+var embeddedFontsLoaded = {};     // family name -> already fetched
+
+function resetEmbeddedTracks() {
+    embeddedProbeToken++;
+    stopAssTrack();
+    embeddedSubtitles = [];
+    embeddedFonts     = [];
+    embeddedMediaPath = null;
+    // Font families are keyed by attachment ordinal, which means the same name
+    // refers to a different font in the next video — drop the old faces.
+    embeddedFontFaces.forEach(function (face) {
+        try { document.fonts.delete(face); } catch (e) {}
+    });
+    embeddedFontFaces   = [];
+    embeddedFontsLoaded = {};
+}
+
+function loadEmbeddedTrackList(filepath) {
+    resetEmbeddedTracks();
+    if (!filepath) { return; }
+
+    embeddedMediaPath = filepath;
+    var token = embeddedProbeToken;
+
+    fetch(SUBTITLE_API + '?file=' + encodeURIComponent(filepath))
+        .then(function (r) { return r.json(); })
+        .then(function (info) {
+            if (token !== embeddedProbeToken) { return; }
+            if (!info || info.error) { return; }
+            embeddedSubtitles = info.subtitles || [];
+            embeddedFonts     = info.fonts || [];
+            updateSubtitleSubmenu();
+            refreshEmbeddedFontOptions();
+        })
+        .catch(function () {});   // embedded tracks are a bonus — fail quietly
+}
+
+function embeddedTrackLabel(track, position) {
+    var label = track.title || '';
+    if (!label) { label = 'Track ' + (position + 1); }
+    if (track.language) { label += ' [' + track.language + ']'; }
+    if (track.forced)   { label += ' (forced)'; }
+    if (!track.textual) { label += ' — image based'; }
+    return label;
+}
+
+// Extraction has to scan the whole container, so it takes a few seconds on a
+// large file. Results are kept in loadedSubtitleFiles, making reselection free.
+function selectEmbeddedSubtitle(track, position) {
+    if (!track.textual) {
+        showToast('That track is image based and cannot be displayed');
+        return;
+    }
+
+    var cacheKey = 'embedded#' + track.index + '@' + embeddedMediaPath;
+    for (var i = 0; i < loadedSubtitleFiles.length; i++) {
+        if (loadedSubtitleFiles[i].path === cacheKey) {
+            activeSubtitleIndex = i;
+            applyActiveSubtitle();
+            updateSubtitleSubmenu();
+            return;
+        }
+    }
+
+    // ASS/SSA is fetched in its native form so the styling survives; everything
+    // else comes through as SubRip for the plain renderer.
+    var styled    = /^(ass|ssa)$/i.test(track.codec) && typeof ASS !== 'undefined';
+    var mediaPath = embeddedMediaPath;
+    var url = SUBTITLE_API + '?file=' + encodeURIComponent(mediaPath) + '&track=' + track.index
+            + (styled ? '&format=ass' : '');
+
+    showToast('Extracting subtitle track…');
+    $.ajax({
+        url: url,
+        dataType: 'text',
+        success: function (content) {
+            if (mediaPath !== embeddedMediaPath) { return; }   // episode changed
+
+            if (styled) {
+                var parsed = ASS.parse(content);
+                if (!parsed.events.length) {
+                    showToast('That track contained no readable text');
+                    return;
+                }
+                loadedSubtitleFiles.push({
+                    path: cacheKey,
+                    name: embeddedTrackLabel(track, position),
+                    cues: [],
+                    assTrack: parsed
+                });
+                activeSubtitleIndex = loadedSubtitleFiles.length - 1;
+                applyActiveSubtitle();
+                updateSubtitleSubmenu();
+                showToast('Subtitle loaded · ' + parsed.events.length + ' styled lines');
+                return;
+            }
+
+            var cues = parseSrt(content);
+            if (cues.length === 0) {
+                showToast('That track contained no readable text');
+                return;
+            }
+            loadedSubtitleFiles.push({
+                path: cacheKey,
+                name: embeddedTrackLabel(track, position),
+                cues: cues
+            });
+            activeSubtitleIndex = loadedSubtitleFiles.length - 1;
+            applyActiveSubtitle();
+            updateSubtitleSubmenu();
+            showToast('Subtitle loaded · ' + cues.length + ' lines');
+        },
+        error: function () { showToast('Could not extract that subtitle track'); }
+    });
+}
+
+// Switch the display between the styled ASS renderer and the plain SubRip
+// overlay depending on what the newly selected track carries.
+function applyActiveSubtitle() {
+    var entry = activeSubtitleIndex >= 0 ? loadedSubtitleFiles[activeSubtitleIndex] : null;
+    if (entry && entry.assTrack) {
+        startAssTrack(entry.assTrack);
+    } else {
+        stopAssTrack();
+        updateSubtitleDisplay();
+    }
+}
+
+// ── Styled ASS rendering ─────────────────────────────────────────────────────
+// ASS tracks are fetched in their native form and drawn by script/ass.js, which
+// preserves per-line styling and position. That is what makes dual-language
+// releases work: the Japanese and Chinese lines are separate events shown at the
+// same time, and a single-cue renderer can only ever display one of them.
+var assRenderer  = null;
+var assActive    = false;
+var assFrameReq  = null;
+
+function initAssRenderer() {
+    var overlay = document.getElementById('ass-overlay');
+    if (overlay && typeof ASS !== 'undefined') { assRenderer = new ASS.Renderer(overlay); }
+}
+
+// Match the overlay to the letterboxed picture inside the video element, so
+// script coordinates map onto what the viewer actually sees.
+function syncAssOverlayGeometry() {
+    if (!assRenderer) { return; }
+    var vid = document.getElementById('main-video');
+    var overlay = document.getElementById('ass-overlay');
+    var cw = vid.clientWidth, ch = vid.clientHeight;
+    var vw = vid.videoWidth, vh = vid.videoHeight;
+
+    var left = 0, top = 0, width = cw, height = ch;
+    if (vw > 0 && vh > 0 && cw > 0 && ch > 0) {
+        var scale = Math.min(cw / vw, ch / vh);
+        width  = vw * scale;
+        height = vh * scale;
+        left   = (cw - width) / 2;
+        top    = (ch - height) / 2;
+    }
+    if (overlay._w !== width || overlay._h !== height || overlay._l !== left || overlay._t !== top) {
+        overlay._w = width; overlay._h = height; overlay._l = left; overlay._t = top;
+        overlay.style.left   = left + 'px';
+        overlay.style.top    = top + 'px';
+        overlay.style.width  = width + 'px';
+        overlay.style.height = height + 'px';
+        assRenderer.resize(width, height);
+    }
+}
+
+function startAssTrack(track) {
+    if (!assRenderer) { return; }
+    stopAssTrack();
+    assRenderer.setTrack(track);
+    assActive = true;
+    $('#ass-overlay').addClass('active');
+    $('#subtitle-display').hide();     // the plain renderer must not double up
+    loadAssFonts(track);
+
+    var step = function () {
+        if (!assActive) { return; }
+        syncAssOverlayGeometry();
+        assRenderer.setTime(effectivePlaybackTime());
+        assFrameReq = requestAnimationFrame(step);
+    };
+    step();
+}
+
+function stopAssTrack() {
+    assActive = false;
+    if (assFrameReq) { cancelAnimationFrame(assFrameReq); assFrameReq = null; }
+    if (assRenderer) { assRenderer.clear(); }
+    $('#ass-overlay').removeClass('active');
+}
+
+// Only pull the fonts the track actually references — a release can attach a
+// dozen and use half of them.
+function loadAssFonts(track) {
+    if (typeof ASS === 'undefined') { return; }
+    ASS.referencedFonts(track).forEach(function (family) {
+        ensureEmbeddedFontLoaded(family);
+    });
+}
+
+// ── Embedded fonts ───────────────────────────────────────────────────────────
+// Registered under the font's own internal family name, which is what ASS styles
+// reference, and offered in Subtitle Settings for SubRip tracks. Fetched lazily:
+// a release can carry a dozen fonts and only a few are ever used.
+function embeddedFontFamily(font) {
+    return font.family || ('aroz-embedded-' + font.index);
+}
+
+function findEmbeddedFontByFamily(family) {
+    var wanted = String(family || '').replace(/^@/, '').toLowerCase();
+    var found = null;
+    embeddedFonts.forEach(function (f) {
+        if (embeddedFontFamily(f).toLowerCase() === wanted) { found = f; }
+    });
+    return found;
+}
+
+function refreshEmbeddedFontOptions() {
+    var $select = $('#sset-font');
+    $select.find('optgroup.embedded-fonts').remove();
+
+    if (embeddedFonts.length > 0) {
+        var $group = $('<optgroup label="Embedded in this video" class="embedded-fonts"></optgroup>');
+        embeddedFonts.forEach(function (font) {
+            $group.append($('<option></option>')
+                .attr('value', embeddedFontFamily(font))
+                .text(font.filename || ('Font ' + (font.index + 1))));
+        });
+        $select.append($group);
+    }
+
+    // A font picked for a previous video may not exist in this one
+    var stillOffered = $select.find('option').filter(function () {
+        return this.value === subtitleSettings.font;
+    }).length > 0;
+    if (!stillOffered) {
+        subtitleSettings.font = 'system-ui, sans-serif';
+        applySubtitleSettings();
+        saveSubtitleSettings();
+    }
+    $select.val(subtitleSettings.font);
+    ensureEmbeddedFontLoaded(subtitleSettings.font);
+}
+
+function ensureEmbeddedFontLoaded(family) {
+    if (!family || embeddedFontsLoaded[family] || !embeddedMediaPath) { return; }
+    if (typeof FontFace === 'undefined' || !document.fonts) { return; }
+
+    var meta = findEmbeddedFontByFamily(family);
+    if (!meta) { return; }   // a built-in font, or one this video does not carry
+
+    embeddedFontsLoaded[family] = true;   // claim it so we do not refetch in a loop
+    var url  = SUBTITLE_API + '?file=' + encodeURIComponent(embeddedMediaPath) + '&font=' + meta.index;
+    var face = new FontFace(embeddedFontFamily(meta), 'url("' + url + '")');
+    face.load().then(function (loaded) {
+        document.fonts.add(loaded);
+        embeddedFontFaces.push(loaded);
+        applySubtitleSettings();
+        syncSettingsPreview();
+        if (assRenderer) { assRenderer.clear(); }   // redraw with the real face
+    }).catch(function () {
+        embeddedFontsLoaded[family] = false;
+    });
+}
+
 function updateSubtitleSubmenu() {
     $('#ctx-subtitle-sub .ctx-sub-dynamic').remove();
     var disabled = (activeSubtitleIndex < 0);
@@ -1452,17 +1767,46 @@ function updateSubtitleSubmenu() {
         .toggleClass('ctx-active', disabled)
         .find('.ctx-icon').text(disabled ? '✓' : '');
 
-    if (loadedSubtitleFiles.length > 0) {
-        var $load = $('#ctx-sub-load');
+    var $load = $('#ctx-sub-load');
+
+    // Tracks muxed into the video, listed whether or not they are loaded yet
+    if (embeddedSubtitles.length > 0) {
+        $('<div class="ctx-divider ctx-sub-dynamic"></div>').insertBefore($load);
+        embeddedSubtitles.forEach(function (track, position) {
+            var cacheKey = 'embedded#' + track.index + '@' + embeddedMediaPath;
+            var isActive = activeSubtitleIndex >= 0 &&
+                           loadedSubtitleFiles[activeSubtitleIndex] &&
+                           loadedSubtitleFiles[activeSubtitleIndex].path === cacheKey;
+            $('<div class="ctx-item ctx-sub-dynamic'
+                + (isActive ? ' ctx-active' : '')
+                + (track.textual ? '' : ' ctx-disabled') + '">'
+                + '<i class="ctx-icon">' + (isActive ? '✓' : '') + '</i>'
+                + escapeHtml(embeddedTrackLabel(track, position))
+                + '</div>')
+            .on('click', function () {
+                selectEmbeddedSubtitle(track, position);
+                $('#player-ctx').hide();
+            })
+            .insertBefore($load);
+        });
+    }
+
+    // Sidecar files the user loaded by hand
+    var external = [];
+    loadedSubtitleFiles.forEach(function (sf, idx) {
+        if (String(sf.path).indexOf('embedded#') !== 0) { external.push({ sf: sf, idx: idx }); }
+    });
+    if (external.length > 0) {
         $('<div class="ctx-divider ctx-sub-dynamic"></div>').insertBefore($load);
-        loadedSubtitleFiles.forEach(function (sf, idx) {
-            var isActive = (idx === activeSubtitleIndex);
-            $('<div class="ctx-item ctx-sub-dynamic' + (isActive ? ' ctx-active' : '') + '" data-sub-idx="' + idx + '">'
+        external.forEach(function (entry) {
+            var isActive = (entry.idx === activeSubtitleIndex);
+            $('<div class="ctx-item ctx-sub-dynamic' + (isActive ? ' ctx-active' : '') + '" data-sub-idx="' + entry.idx + '">'
                 + '<i class="ctx-icon">' + (isActive ? '✓' : '') + '</i>'
-                + escapeHtml(sf.name)
+                + escapeHtml(entry.sf.name)
                 + '</div>')
             .on('click', function () {
                 activeSubtitleIndex = parseInt($(this).data('sub-idx'), 10);
+                applyActiveSubtitle();
                 updateSubtitleSubmenu();
                 $('#player-ctx').hide();
             })
@@ -1499,6 +1843,7 @@ function initSubtitleMenu() {
 
     $('#ctx-sub-disable').on('click', function () {
         activeSubtitleIndex = -1;
+        stopAssTrack();
         $('#subtitle-display').hide();
         updateSubtitleSubmenu();
         $ctx.hide();
@@ -1600,7 +1945,11 @@ function initMain(){
     initContextMenu();
     initSettingsPopup();
     initScrubPreview();
-    if (currentFile) { scheduleStoryboardLoad(currentFile.filepath); }
+    initAssRenderer();
+    if (currentFile) {
+        scheduleStoryboardLoad(currentFile.filepath);
+        loadEmbeddedTrackList(currentFile.filepath);
+    }
     initSubtitleMenu();
     initSubtitleSettings();
     initKeyboard();

+ 354 - 8
src/web/Movie/index.html

@@ -16,6 +16,9 @@
     <!-- App path config (single source of truth for all API paths) -->
     <script src="backend/common.js"></script>
 
+    <!-- ASS/SSA subtitle parser + renderer (see script/ass.js) -->
+    <script src="script/ass.js"></script>
+
 <style>
 /* ─── Reset & base ──────────────────────────────────────────────────────────── */
 *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
@@ -522,6 +525,23 @@ body.always-show-volume #volume-slider { display: block; }
     100% { opacity: 0;    transform: scale(1.32); }
 }
 
+/* ─── ASS subtitle overlay ───────────────────────────────────────────────────── */
+/* Sized in JS to the video's displayed rect so script coordinates land correctly
+   regardless of letterboxing. Children are absolutely positioned by the renderer. */
+#ass-overlay {
+    display: none;
+    position: absolute;
+    left: 0; top: 0; width: 0; height: 0;
+    pointer-events: none;
+    overflow: hidden;
+    z-index: 14;
+    /* libass spaces lines by the font's own ascent+descent with no extra
+       leading; 1.2 would push stacked dual-language lines apart and break the
+       MarginV offsets releases use to sit them one above the other. */
+    line-height: 1.05;
+}
+#ass-overlay.active { display: block; }
+
 /* ─── Scrub-bar hover preview ────────────────────────────────────────────────── */
 #scrub-preview {
     display: none;
@@ -1275,6 +1295,7 @@ img.ctx-icon { height: 16px; opacity: 0.85; display: block; }
     border: 1px solid rgba(255,255,255,0.08); border-radius: 7px;
     padding: 5px 8px; font-size: 12px; cursor: pointer; outline: none;
     appearance: auto;
+    width: 100%;
 }
 .sset-colors { display: flex; align-items: center; gap: 7px; flex-wrap: wrap; flex: 1; }
 .sset-color {
@@ -1621,6 +1642,10 @@ img.ctx-icon { height: 16px; opacity: 0.85; display: block; }
                 </div>
             </div>
 
+            <!-- Styled ASS/SSA subtitles are drawn here, sized to the video's
+                 displayed rectangle. The plain #subtitle-display below is used
+                 for SubRip tracks. -->
+            <div id="ass-overlay"></div>
             <div id="subtitle-display"></div>
 
             <!-- Centre play/pause flash indicator -->
@@ -2736,6 +2761,7 @@ $(document).ready(function () {
     initContextMenu();
     initSettingsPopup();
     initScrubPreview();
+    initAssRenderer();
     initSubtitleMenu();
     initSubtitleSettings();
 
@@ -3364,6 +3390,7 @@ function startPlayback(index) {
     ao_module_setWindowTitle('Movie – ' + ep.name);
 
     scheduleStoryboardLoad(ep.filepath);
+    loadEmbeddedTrackList(ep.filepath);
 
     renderSidebar(currentEpisodes, index);
 
@@ -3462,6 +3489,7 @@ function closePlayer() {
     cancelCountdown();
     hideSeekFreeze();
     resetStoryboard();
+    resetEmbeddedTracks();
     if (watchSaveInterval) { clearInterval(watchSaveInterval); watchSaveInterval = null; }
     var vid = document.getElementById('main-video');
     vid.pause();
@@ -4259,6 +4287,7 @@ function initSubtitleSettings() {
     // Font
     $('#sset-font').on('change', function () {
         subtitleSettings.font = $(this).val();
+        ensureEmbeddedFontLoaded(subtitleSettings.font);   // no-op for built-in fonts
         applySubtitleSettings();
         syncSettingsPreview();
         saveSubtitleSettings();
@@ -4326,6 +4355,7 @@ window.movieOnSubtitleFile = function (raw) {
                 activeSubtitleIndex = loadedSubtitleFiles.length - 1;
             }
             showToast('Subtitle loaded: ' + name);
+            applyActiveSubtitle();   // a sidecar file replaces any styled track
             updateSubtitleSubmenu();
         },
         error: function (xhr, status, err) {
@@ -4355,15 +4385,22 @@ function parseSrt(content) {
         if (parts.length < 2) { return; }
         var start = parseSrtTime(parts[0].trim());
         var end   = parseSrtTime(parts[1].trim().split(' ')[0]);
-        // Strip inline SRT tags like <b>, <i>, <u>, <font>
+        // Strip inline SRT tags like <b>, <i>, <u>, <font>, plus any ASS
+        // override blocks such as {\an8} or {\pos(x,y)}. ffmpeg's ASS-to-SubRip
+        // conversion leaves positioning tags like those behind, so without this
+        // they would be shown to the viewer as literal text.
         var text  = lines.slice(tcIdx + 1).join('\n').trim()
-                        .replace(/<[^>]+>/g, '');
+                        .replace(/<[^>]+>/g, '')
+                        .replace(/\{\\[^}]*\}/g, '')
+                        .trim();
         if (text) { cues.push({ start: start, end: end, text: text }); }
     });
     return cues;
 }
 
 function updateSubtitleDisplay() {
+    // Styled tracks are drawn by the ASS renderer on its own frame loop
+    if (assActive) { $('#subtitle-display').hide(); return; }
     if (activeSubtitleIndex < 0 || !loadedSubtitleFiles[activeSubtitleIndex]) {
         $('#subtitle-display').hide();
         return;
@@ -4383,6 +4420,285 @@ function updateSubtitleDisplay() {
     }
 }
 
+// ─── Embedded subtitle tracks and fonts ───────────────────────────────────────
+// MKV releases usually mux their subtitles (and the fonts they were typeset in)
+// into the container rather than shipping sidecar files. The server lists them
+// and extracts on demand; tracks are converted to SubRip, which keeps the text
+// and timing but drops ASS styling, positioning and karaoke.
+var embeddedSubtitles   = [];     // [{index, codec, language, title, forced, textual}]
+var embeddedFonts       = [];     // [{index, filename, mimetype}]
+var embeddedMediaPath   = null;   // file the two lists above describe
+var embeddedProbeToken  = 0;      // invalidates in-flight probes on episode change
+var embeddedFontFaces   = [];     // FontFace objects registered for this video
+var embeddedFontsLoaded = {};     // family name -> already fetched
+
+function resetEmbeddedTracks() {
+    embeddedProbeToken++;
+    stopAssTrack();
+    embeddedSubtitles = [];
+    embeddedFonts     = [];
+    embeddedMediaPath = null;
+    // Font families are keyed by attachment ordinal, which means the same name
+    // refers to a different font in the next video — drop the old faces.
+    embeddedFontFaces.forEach(function (face) {
+        try { document.fonts.delete(face); } catch (e) {}
+    });
+    embeddedFontFaces   = [];
+    embeddedFontsLoaded = {};
+}
+
+function loadEmbeddedTrackList(filepath) {
+    resetEmbeddedTracks();
+    if (!filepath) { return; }
+
+    embeddedMediaPath = filepath;
+    var token = embeddedProbeToken;
+
+    fetch(SUBTITLE_API + '?file=' + encodeURIComponent(filepath))
+        .then(function (r) { return r.json(); })
+        .then(function (info) {
+            if (token !== embeddedProbeToken) { return; }   // episode changed
+            if (!info || info.error) { return; }
+            embeddedSubtitles = info.subtitles || [];
+            embeddedFonts     = info.fonts || [];
+            updateSubtitleSubmenu();
+            refreshEmbeddedFontOptions();
+        })
+        .catch(function () {});   // embedded tracks are a bonus — fail quietly
+}
+
+function embeddedTrackLabel(track, position) {
+    var label = track.title || '';
+    if (!label) { label = 'Track ' + (position + 1); }
+    if (track.language) { label += ' [' + track.language + ']'; }
+    if (track.forced)   { label += ' (forced)'; }
+    if (!track.textual) { label += ' — image based'; }
+    return label;
+}
+
+// Extraction has to scan the whole container, so it takes a few seconds on a
+// large file. Results are kept in loadedSubtitleFiles, making reselection free.
+function selectEmbeddedSubtitle(track, position) {
+    if (!track.textual) {
+        showToast('That track is image based and cannot be displayed');
+        return;
+    }
+
+    var cacheKey = 'embedded#' + track.index + '@' + embeddedMediaPath;
+    for (var i = 0; i < loadedSubtitleFiles.length; i++) {
+        if (loadedSubtitleFiles[i].path === cacheKey) {
+            activeSubtitleIndex = i;
+            applyActiveSubtitle();
+            updateSubtitleSubmenu();
+            return;
+        }
+    }
+
+    // ASS/SSA is fetched in its native form so the styling survives; everything
+    // else comes through as SubRip for the plain renderer.
+    var styled    = /^(ass|ssa)$/i.test(track.codec) && typeof ASS !== 'undefined';
+    var mediaPath = embeddedMediaPath;
+    var url = SUBTITLE_API + '?file=' + encodeURIComponent(mediaPath) + '&track=' + track.index
+            + (styled ? '&format=ass' : '');
+
+    showToast('Extracting subtitle track…');
+    $.ajax({
+        url: url,
+        dataType: 'text',
+        success: function (content) {
+            if (mediaPath !== embeddedMediaPath) { return; }   // episode changed
+
+            if (styled) {
+                var parsed = ASS.parse(content);
+                if (!parsed.events.length) {
+                    showToast('That track contained no readable text');
+                    return;
+                }
+                loadedSubtitleFiles.push({
+                    path: cacheKey,
+                    name: embeddedTrackLabel(track, position),
+                    cues: [],
+                    assTrack: parsed
+                });
+                activeSubtitleIndex = loadedSubtitleFiles.length - 1;
+                applyActiveSubtitle();
+                updateSubtitleSubmenu();
+                showToast('Subtitle loaded · ' + parsed.events.length + ' styled lines');
+                return;
+            }
+
+            var cues = parseSrt(content);
+            if (cues.length === 0) {
+                showToast('That track contained no readable text');
+                return;
+            }
+            loadedSubtitleFiles.push({
+                path: cacheKey,
+                name: embeddedTrackLabel(track, position),
+                cues: cues
+            });
+            activeSubtitleIndex = loadedSubtitleFiles.length - 1;
+            applyActiveSubtitle();
+            updateSubtitleSubmenu();
+            showToast('Subtitle loaded · ' + cues.length + ' lines');
+        },
+        error: function () { showToast('Could not extract that subtitle track'); }
+    });
+}
+
+// Switch the display between the styled ASS renderer and the plain SubRip
+// overlay depending on what the newly selected track carries.
+function applyActiveSubtitle() {
+    var entry = activeSubtitleIndex >= 0 ? loadedSubtitleFiles[activeSubtitleIndex] : null;
+    if (entry && entry.assTrack) {
+        startAssTrack(entry.assTrack);
+    } else {
+        stopAssTrack();
+        updateSubtitleDisplay();
+    }
+}
+
+// ─── Styled ASS rendering ─────────────────────────────────────────────────────
+// ASS tracks are fetched in their native form and drawn by script/ass.js, which
+// preserves per-line styling and position. That is what makes dual-language
+// releases work: the Japanese and Chinese lines are separate events shown at the
+// same time, and a single-cue renderer can only ever display one of them.
+var assRenderer  = null;
+var assActive    = false;
+var assFrameReq  = null;
+
+function initAssRenderer() {
+    var overlay = document.getElementById('ass-overlay');
+    if (overlay && typeof ASS !== 'undefined') { assRenderer = new ASS.Renderer(overlay); }
+}
+
+// Match the overlay to the letterboxed picture inside the video element, so
+// script coordinates map onto what the viewer actually sees.
+function syncAssOverlayGeometry() {
+    if (!assRenderer) { return; }
+    var vid = document.getElementById('main-video');
+    var overlay = document.getElementById('ass-overlay');
+    var cw = vid.clientWidth, ch = vid.clientHeight;
+    var vw = vid.videoWidth, vh = vid.videoHeight;
+
+    var left = 0, top = 0, width = cw, height = ch;
+    if (vw > 0 && vh > 0 && cw > 0 && ch > 0) {
+        var scale = Math.min(cw / vw, ch / vh);
+        width  = vw * scale;
+        height = vh * scale;
+        left   = (cw - width) / 2;
+        top    = (ch - height) / 2;
+    }
+    if (overlay._w !== width || overlay._h !== height || overlay._l !== left || overlay._t !== top) {
+        overlay._w = width; overlay._h = height; overlay._l = left; overlay._t = top;
+        overlay.style.left   = left + 'px';
+        overlay.style.top    = top + 'px';
+        overlay.style.width  = width + 'px';
+        overlay.style.height = height + 'px';
+        assRenderer.resize(width, height);
+    }
+}
+
+function startAssTrack(track) {
+    if (!assRenderer) { return; }
+    stopAssTrack();
+    assRenderer.setTrack(track);
+    assActive = true;
+    $('#ass-overlay').addClass('active');
+    $('#subtitle-display').hide();     // the plain renderer must not double up
+    loadAssFonts(track);
+
+    var step = function () {
+        if (!assActive) { return; }
+        syncAssOverlayGeometry();
+        assRenderer.setTime(effectivePlaybackTime());
+        assFrameReq = requestAnimationFrame(step);
+    };
+    step();
+}
+
+function stopAssTrack() {
+    assActive = false;
+    if (assFrameReq) { cancelAnimationFrame(assFrameReq); assFrameReq = null; }
+    if (assRenderer) { assRenderer.clear(); }
+    $('#ass-overlay').removeClass('active');
+}
+
+// Only pull the fonts the track actually references — a release can attach a
+// dozen and use half of them.
+function loadAssFonts(track) {
+    if (typeof ASS === 'undefined') { return; }
+    ASS.referencedFonts(track).forEach(function (family) {
+        ensureEmbeddedFontLoaded(family);
+    });
+}
+
+// ─── Embedded fonts ───────────────────────────────────────────────────────────
+// Registered under the font's own internal family name, which is what ASS styles
+// reference, and offered in Subtitle Settings for SubRip tracks. Fetched lazily:
+// a release can carry a dozen fonts and only a few are ever used.
+function embeddedFontFamily(font) {
+    return font.family || ('aroz-embedded-' + font.index);
+}
+
+function findEmbeddedFontByFamily(family) {
+    var wanted = String(family || '').replace(/^@/, '').toLowerCase();
+    var found = null;
+    embeddedFonts.forEach(function (f) {
+        if (embeddedFontFamily(f).toLowerCase() === wanted) { found = f; }
+    });
+    return found;
+}
+
+function refreshEmbeddedFontOptions() {
+    var $select = $('#sset-font');
+    $select.find('optgroup.embedded-fonts').remove();
+
+    if (embeddedFonts.length > 0) {
+        var $group = $('<optgroup label="Embedded in this video" class="embedded-fonts"></optgroup>');
+        embeddedFonts.forEach(function (font) {
+            $group.append($('<option></option>')
+                .attr('value', embeddedFontFamily(font))
+                .text(font.filename || ('Font ' + (font.index + 1))));
+        });
+        $select.append($group);
+    }
+
+    // A font picked for a previous video may not exist in this one. Anything no
+    // longer offered by the dropdown (built-in or embedded) falls back.
+    var stillOffered = $select.find('option').filter(function () {
+        return this.value === subtitleSettings.font;
+    }).length > 0;
+    if (!stillOffered) {
+        subtitleSettings.font = 'system-ui, sans-serif';
+        applySubtitleSettings();
+        saveSubtitleSettings();
+    }
+    $select.val(subtitleSettings.font);
+    ensureEmbeddedFontLoaded(subtitleSettings.font);
+}
+
+function ensureEmbeddedFontLoaded(family) {
+    if (!family || embeddedFontsLoaded[family] || !embeddedMediaPath) { return; }
+    if (typeof FontFace === 'undefined' || !document.fonts) { return; }
+
+    var meta = findEmbeddedFontByFamily(family);
+    if (!meta) { return; }   // a built-in font, or one this video does not carry
+
+    embeddedFontsLoaded[family] = true;   // claim it so we do not refetch in a loop
+    var url  = SUBTITLE_API + '?file=' + encodeURIComponent(embeddedMediaPath) + '&font=' + meta.index;
+    var face = new FontFace(embeddedFontFamily(meta), 'url("' + url + '")');
+    face.load().then(function (loaded) {
+        document.fonts.add(loaded);
+        embeddedFontFaces.push(loaded);
+        applySubtitleSettings();
+        syncSettingsPreview();
+        if (assRenderer) { assRenderer.clear(); }   // redraw with the real face
+    }).catch(function () {
+        embeddedFontsLoaded[family] = false;
+    });
+}
+
 function updateSubtitleSubmenu() {
     // Remove previously injected dynamic entries
     $('#ctx-subtitle-sub .ctx-sub-dynamic').remove();
@@ -4392,17 +4708,46 @@ function updateSubtitleSubmenu() {
         .toggleClass('ctx-active', disabled)
         .find('.ctx-icon').text(disabled ? '✓' : '');
 
-    if (loadedSubtitleFiles.length > 0) {
-        var $load = $('#ctx-sub-load');
+    var $load = $('#ctx-sub-load');
+
+    // Tracks muxed into the video, listed whether or not they are loaded yet
+    if (embeddedSubtitles.length > 0) {
+        $('<div class="ctx-divider ctx-sub-dynamic"></div>').insertBefore($load);
+        embeddedSubtitles.forEach(function (track, position) {
+            var cacheKey = 'embedded#' + track.index + '@' + embeddedMediaPath;
+            var isActive = activeSubtitleIndex >= 0 &&
+                           loadedSubtitleFiles[activeSubtitleIndex] &&
+                           loadedSubtitleFiles[activeSubtitleIndex].path === cacheKey;
+            $('<div class="ctx-item ctx-sub-dynamic'
+                + (isActive ? ' ctx-active' : '')
+                + (track.textual ? '' : ' ctx-disabled') + '">'
+                + '<i class="ctx-icon">' + (isActive ? '✓' : '') + '</i>'
+                + escapeHtml(embeddedTrackLabel(track, position))
+                + '</div>')
+            .on('click', function () {
+                selectEmbeddedSubtitle(track, position);
+                $('#player-ctx').hide();
+            })
+            .insertBefore($load);
+        });
+    }
+
+    // Sidecar files the user loaded by hand
+    var external = [];
+    loadedSubtitleFiles.forEach(function (sf, idx) {
+        if (String(sf.path).indexOf('embedded#') !== 0) { external.push({ sf: sf, idx: idx }); }
+    });
+    if (external.length > 0) {
         $('<div class="ctx-divider ctx-sub-dynamic"></div>').insertBefore($load);
-        loadedSubtitleFiles.forEach(function (sf, idx) {
-            var isActive = (idx === activeSubtitleIndex);
-            $('<div class="ctx-item ctx-sub-dynamic' + (isActive ? ' ctx-active' : '') + '" data-sub-idx="' + idx + '">' +
+        external.forEach(function (entry) {
+            var isActive = (entry.idx === activeSubtitleIndex);
+            $('<div class="ctx-item ctx-sub-dynamic' + (isActive ? ' ctx-active' : '') + '" data-sub-idx="' + entry.idx + '">' +
                 '<i class="ctx-icon">' + (isActive ? '✓' : '') + '</i>' +
-                escapeHtml(sf.name) +
+                escapeHtml(entry.sf.name) +
               '</div>')
             .on('click', function () {
                 activeSubtitleIndex = parseInt($(this).data('sub-idx'), 10);
+                applyActiveSubtitle();
                 updateSubtitleSubmenu();
                 $('#player-ctx').hide();
             })
@@ -4441,6 +4786,7 @@ function initSubtitleMenu() {
 
     $('#ctx-sub-disable').on('click', function () {
         activeSubtitleIndex = -1;
+        stopAssTrack();
         $('#subtitle-display').hide();
         updateSubtitleSubmenu();
         $ctx.hide();

+ 624 - 0
src/web/Movie/script/ass.js

@@ -0,0 +1,624 @@
+/*
+    ass.js — Advanced SubStation Alpha parser and DOM renderer
+
+    Renders ASS/SSA subtitle tracks with their original styling instead of
+    flattening them to plain text. That matters for releases which show two
+    languages at once: a Japanese line pinned near the top and a Chinese line
+    below it are two separate events with different styles, and any renderer
+    that only shows "the cue at time t" will silently drop one of them.
+
+    Written in-repo rather than pulling in libass/WASM, so there is no binary
+    blob to vendor and nothing to fetch at runtime.
+
+    Supported
+      • [V4+ Styles] and [V4 Styles]: font, size, colours, bold/italic/underline/
+        strikeout, outline, shadow, opaque box, alignment, margins, spacing
+      • Positioning: \an, \a, \pos, \move (start point), margins, PlayRes scaling
+      • Inline overrides: \b \i \u \s \fn \fs \fsp \c \1c \2c \3c \4c
+        \alpha \1a \2a \3a \4a \bord \shad \frz \fad \fade \r
+      • Line breaks (\N, \n, \h), layer ordering, simultaneous events
+      • Karaoke tags are stripped so the text still reads correctly
+
+    Not supported (degrades rather than breaks)
+      • Vector drawing (\p) — those events are skipped instead of drawn as text
+      • Animation (\t), clipping (\clip), 3D rotation (\frx, \fry), \org
+      • ScaleX/ScaleY, WrapStyle nuances beyond normal wrapping
+*/
+(function (global) {
+'use strict';
+
+// ─── Parsing ──────────────────────────────────────────────────────────────────
+
+// "0:03:02.65" -> 182.65
+function parseTime(value) {
+    var m = String(value).trim().match(/^(\d+):(\d+):(\d+)(?:[.,](\d+))?$/);
+    if (!m) { return 0; }
+    var frac = m[4] ? parseFloat('0.' + m[4]) : 0;
+    return parseInt(m[1], 10) * 3600 + parseInt(m[2], 10) * 60 + parseInt(m[3], 10) + frac;
+}
+
+// ASS colours are &HAABBGGRR — alpha is inverted (00 = opaque)
+function parseColour(value) {
+    var hex = String(value).trim().replace(/^&[Hh]/, '').replace(/&$/, '');
+    if (!/^[0-9a-fA-F]+$/.test(hex)) { return { r: 255, g: 255, b: 255, a: 1 }; }
+    var n = parseInt(hex.padStart(8, '0').slice(-8), 16);
+    return {
+        r: n & 0xff,
+        g: (n >> 8) & 0xff,
+        b: (n >> 16) & 0xff,
+        a: 1 - (((n >> 24) & 0xff) / 255)
+    };
+}
+
+function colourToCss(c) {
+    return 'rgba(' + c.r + ',' + c.g + ',' + c.b + ',' + c.a.toFixed(3) + ')';
+}
+
+// ASS alpha overrides are &HAA& where 00 is opaque
+function parseAlpha(value) {
+    var hex = String(value).trim().replace(/^&[Hh]/, '').replace(/&$/, '');
+    if (!/^[0-9a-fA-F]+$/.test(hex)) { return 1; }
+    return 1 - (parseInt(hex.slice(-2), 16) / 255);
+}
+
+function toNumber(value, fallback) {
+    var n = parseFloat(value);
+    return isNaN(n) ? fallback : n;
+}
+
+// Split a "Format:" line into trimmed field names
+function parseFormat(line) {
+    return line.slice(line.indexOf(':') + 1).split(',').map(function (s) { return s.trim(); });
+}
+
+// Split a data line into values, keeping the final field (Text) intact even
+// though it legitimately contains commas.
+function splitFields(line, count) {
+    var body = line.slice(line.indexOf(':') + 1);
+    var parts = [];
+    var start = 0;
+    for (var i = 0; i < body.length && parts.length < count - 1; i++) {
+        if (body[i] === ',') {
+            parts.push(body.slice(start, i).trim());
+            start = i + 1;
+        }
+    }
+    parts.push(body.slice(start));
+    return parts;
+}
+
+function defaultStyle() {
+    return {
+        name: 'Default',
+        fontname: 'Arial',
+        fontsize: 48,
+        primary: { r: 255, g: 255, b: 255, a: 1 },
+        secondary: { r: 255, g: 0, b: 0, a: 1 },
+        outlineColour: { r: 0, g: 0, b: 0, a: 1 },
+        backColour: { r: 0, g: 0, b: 0, a: 1 },
+        bold: false, italic: false, underline: false, strikeout: false,
+        spacing: 0, angle: 0,
+        borderStyle: 1, outline: 2, shadow: 0,
+        alignment: 2,
+        marginL: 10, marginR: 10, marginV: 10
+    };
+}
+
+function parseStyleLine(fields, values) {
+    var s = defaultStyle();
+    for (var i = 0; i < fields.length && i < values.length; i++) {
+        var v = values[i];
+        switch (fields[i]) {
+            case 'Name':            s.name = v; break;
+            case 'Fontname':        s.fontname = v; break;
+            case 'Fontsize':        s.fontsize = toNumber(v, 48); break;
+            case 'PrimaryColour':   s.primary = parseColour(v); break;
+            case 'SecondaryColour': s.secondary = parseColour(v); break;
+            case 'OutlineColour':
+            case 'TertiaryColour':  s.outlineColour = parseColour(v); break;
+            case 'BackColour':      s.backColour = parseColour(v); break;
+            // ASS booleans are -1 for true
+            case 'Bold':            s.bold = toNumber(v, 0) !== 0; break;
+            case 'Italic':          s.italic = toNumber(v, 0) !== 0; break;
+            case 'Underline':       s.underline = toNumber(v, 0) !== 0; break;
+            case 'StrikeOut':       s.strikeout = toNumber(v, 0) !== 0; break;
+            case 'Spacing':         s.spacing = toNumber(v, 0); break;
+            case 'Angle':           s.angle = toNumber(v, 0); break;
+            case 'BorderStyle':     s.borderStyle = toNumber(v, 1); break;
+            case 'Outline':         s.outline = toNumber(v, 2); break;
+            case 'Shadow':          s.shadow = toNumber(v, 0); break;
+            case 'Alignment':       s.alignment = normaliseAlignment(toNumber(v, 2), false); break;
+            case 'MarginL':         s.marginL = toNumber(v, 10); break;
+            case 'MarginR':         s.marginR = toNumber(v, 10); break;
+            case 'MarginV':         s.marginV = toNumber(v, 10); break;
+        }
+    }
+    return s;
+}
+
+// Legacy SSA uses a different alignment numbering (1-3 bottom, 5-7 top,
+// 9-11 middle); ASS uses numpad layout. Normalise everything to numpad.
+function normaliseAlignment(value, isLegacy) {
+    if (!isLegacy) {
+        return (value >= 1 && value <= 9) ? value : 2;
+    }
+    var horizontal = ((value - 1) % 4);      // 0 left, 1 centre, 2 right
+    if (horizontal > 2) { horizontal = 1; }
+    if (value >= 9)      { return 4 + horizontal; }   // middle row
+    if (value >= 5)      { return 7 + horizontal; }   // top row
+    return 1 + horizontal;                            // bottom row
+}
+
+function parse(text) {
+    var track = {
+        playResX: 384, playResY: 288,
+        wrapStyle: 0,
+        scaledBorderAndShadow: true,
+        styles: {},
+        events: []
+    };
+
+    var lines = String(text).replace(/^\uFEFF/, '').replace(/\r\n/g, '\n').replace(/\r/g, '\n').split('\n');
+    var section = '';
+    var styleFormat = null;
+    var eventFormat = null;
+    var legacyStyles = false;
+
+    for (var i = 0; i < lines.length; i++) {
+        var line = lines[i].trim();
+        if (!line || line.charAt(0) === ';') { continue; }
+
+        if (line.charAt(0) === '[') {
+            section = line.replace(/^\[|\]$/g, '').toLowerCase();
+            legacyStyles = (section === 'v4 styles');
+            continue;
+        }
+
+        if (section === 'script info') {
+            var kv = line.split(':');
+            if (kv.length < 2) { continue; }
+            var key = kv[0].trim().toLowerCase();
+            var val = kv.slice(1).join(':').trim();
+            if (key === 'playresx') { track.playResX = toNumber(val, 384); }
+            else if (key === 'playresy') { track.playResY = toNumber(val, 288); }
+            else if (key === 'wrapstyle') { track.wrapStyle = toNumber(val, 0); }
+            else if (key === 'scaledborderandshadow') {
+                track.scaledBorderAndShadow = /^yes$/i.test(val);
+            }
+            continue;
+        }
+
+        if (section === 'v4+ styles' || section === 'v4 styles') {
+            if (/^Format\s*:/i.test(line)) { styleFormat = parseFormat(line); continue; }
+            if (/^Style\s*:/i.test(line) && styleFormat) {
+                var sv = splitFields(line, styleFormat.length);
+                var style = parseStyleLine(styleFormat, sv);
+                if (legacyStyles) {
+                    // re-normalise using the legacy numbering
+                    var rawAlign = sv[styleFormat.indexOf('Alignment')];
+                    style.alignment = normaliseAlignment(toNumber(rawAlign, 2), true);
+                }
+                track.styles[style.name] = style;
+            }
+            continue;
+        }
+
+        if (section === 'events') {
+            if (/^Format\s*:/i.test(line)) { eventFormat = parseFormat(line); continue; }
+            if (/^Dialogue\s*:/i.test(line) && eventFormat) {
+                var ev = parseEventLine(eventFormat, splitFields(line, eventFormat.length));
+                if (ev) { track.events.push(ev); }
+            }
+            continue;
+        }
+    }
+
+    if (!track.styles.Default) { track.styles.Default = defaultStyle(); }
+    // Stable ordering: by layer, then by start, so z-index follows the format's rules
+    track.events.sort(function (a, b) {
+        return (a.layer - b.layer) || (a.start - b.start);
+    });
+    return track;
+}
+
+function parseEventLine(fields, values) {
+    var ev = { layer: 0, start: 0, end: 0, style: 'Default', marginL: 0, marginR: 0, marginV: 0, text: '' };
+    for (var i = 0; i < fields.length && i < values.length; i++) {
+        switch (fields[i]) {
+            case 'Layer':
+            case 'Marked':  ev.layer = toNumber(String(values[i]).replace(/^Marked=/i, ''), 0); break;
+            case 'Start':   ev.start = parseTime(values[i]); break;
+            case 'End':     ev.end = parseTime(values[i]); break;
+            case 'Style':   ev.style = String(values[i]).replace(/^\*+/, '') || 'Default'; break;
+            case 'MarginL': ev.marginL = toNumber(values[i], 0); break;
+            case 'MarginR': ev.marginR = toNumber(values[i], 0); break;
+            case 'MarginV': ev.marginV = toNumber(values[i], 0); break;
+            case 'Text':    ev.text = values[i]; break;
+        }
+    }
+    if (ev.end <= ev.start) { return null; }
+    return ev;
+}
+
+// ─── Override tag handling ────────────────────────────────────────────────────
+
+// Split event text into runs, each with its own formatting state. Block-level
+// effects (position, alignment, fade) are collected onto the returned object.
+function buildRuns(text, baseStyle, styles) {
+    var block = { align: null, pos: null, fade: null, rotate: 0, drawing: false };
+    var runs = [];
+    var current = cloneRunState(baseStyle);
+    var buffer = '';
+
+    function flush() {
+        if (buffer.length > 0) {
+            runs.push({ state: cloneRunState(current), text: buffer });
+            buffer = '';
+        }
+    }
+
+    var i = 0;
+    while (i < text.length) {
+        var ch = text.charAt(i);
+
+        if (ch === '\\' && i + 1 < text.length) {
+            var next = text.charAt(i + 1);
+            if (next === 'N') { flush(); runs.push({ lineBreak: true }); i += 2; continue; }
+            if (next === 'n') { flush(); runs.push({ lineBreak: true }); i += 2; continue; }
+            if (next === 'h') { buffer += '\u00a0'; i += 2; continue; }
+        }
+
+        if (ch === '{') {
+            var close = text.indexOf('}', i);
+            if (close === -1) { buffer += text.slice(i); break; }
+            flush();
+            applyOverrides(text.slice(i + 1, close), current, block, baseStyle, styles);
+            i = close + 1;
+            continue;
+        }
+
+        buffer += ch;
+        i++;
+    }
+    flush();
+
+    return { runs: runs, block: block };
+}
+
+function cloneRunState(style) {
+    return {
+        fontname: style.fontname,
+        fontsize: style.fontsize,
+        primary: style.primary,
+        outlineColour: style.outlineColour,
+        backColour: style.backColour,
+        bold: style.bold,
+        italic: style.italic,
+        underline: style.underline,
+        strikeout: style.strikeout,
+        spacing: style.spacing,
+        outline: style.outline,
+        shadow: style.shadow,
+        borderStyle: style.borderStyle
+    };
+}
+
+function applyOverrides(chunk, state, block, baseStyle, styles) {
+    // Each override starts with a backslash; arguments may contain commas and
+    // nested parentheses, so match the tag name then take everything up to the
+    // next backslash that is not inside parentheses.
+    var i = 0;
+    while (i < chunk.length) {
+        if (chunk.charAt(i) !== '\\') { i++; continue; }
+        var j = i + 1;
+        var depth = 0;
+        while (j < chunk.length) {
+            var c = chunk.charAt(j);
+            if (c === '(') { depth++; }
+            else if (c === ')') { depth--; }
+            else if (c === '\\' && depth <= 0) { break; }
+            j++;
+        }
+        applyOneOverride(chunk.slice(i + 1, j), state, block, baseStyle, styles);
+        i = j;
+    }
+}
+
+function applyOneOverride(tag, state, block, baseStyle, styles) {
+    var m;
+
+    if ((m = tag.match(/^an(\d+)$/i)))  { block.align = normaliseAlignment(parseInt(m[1], 10), false); return; }
+    if ((m = tag.match(/^a(\d+)$/i)))   { block.align = normaliseAlignment(parseInt(m[1], 10), true); return; }
+    if ((m = tag.match(/^pos\(\s*([-\d.]+)\s*,\s*([-\d.]+)\s*\)$/i))) {
+        block.pos = { x: parseFloat(m[1]), y: parseFloat(m[2]) }; return;
+    }
+    if ((m = tag.match(/^move\(\s*([-\d.]+)\s*,\s*([-\d.]+)/i))) {
+        // Animation is unsupported; anchor at the start point so the line at
+        // least appears in a sensible place.
+        block.pos = { x: parseFloat(m[1]), y: parseFloat(m[2]) }; return;
+    }
+    if ((m = tag.match(/^fad\(\s*([\d.]+)\s*,\s*([\d.]+)\s*\)$/i))) {
+        block.fade = { inMs: parseFloat(m[1]), outMs: parseFloat(m[2]) }; return;
+    }
+    if ((m = tag.match(/^fade\(\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)/i))) {
+        block.fade = { inMs: parseFloat(m[2]), outMs: parseFloat(m[3]) }; return;
+    }
+    if ((m = tag.match(/^frz?([-\d.]+)$/i))) { block.rotate = parseFloat(m[1]); return; }
+    if (/^p[1-9]\d*$/i.test(tag))            { block.drawing = true; return; }
+    if (/^p0$/i.test(tag))                   { block.drawing = false; return; }
+
+    if ((m = tag.match(/^r(.*)$/i)) && !/^rnd/i.test(tag)) {
+        var target = m[1].trim();
+        var reset = (target && styles[target]) ? styles[target] : baseStyle;
+        var fresh = cloneRunState(reset);
+        for (var k in fresh) { if (Object.prototype.hasOwnProperty.call(fresh, k)) { state[k] = fresh[k]; } }
+        return;
+    }
+
+    if ((m = tag.match(/^b(\d+)$/i)))  { state.bold = parseInt(m[1], 10) !== 0; return; }
+    if ((m = tag.match(/^i(\d)$/i)))   { state.italic = m[1] !== '0'; return; }
+    if ((m = tag.match(/^u(\d)$/i)))   { state.underline = m[1] !== '0'; return; }
+    if ((m = tag.match(/^s(\d)$/i)))   { state.strikeout = m[1] !== '0'; return; }
+    if ((m = tag.match(/^fn(.+)$/i)))  { state.fontname = m[1].trim(); return; }
+    if ((m = tag.match(/^fs([\d.]+)$/i)))  { state.fontsize = parseFloat(m[1]); return; }
+    if ((m = tag.match(/^fsp([-\d.]+)$/i))) { state.spacing = parseFloat(m[1]); return; }
+    if ((m = tag.match(/^bord([\d.]+)$/i))) { state.outline = parseFloat(m[1]); return; }
+    if ((m = tag.match(/^shad([\d.]+)$/i))) { state.shadow = parseFloat(m[1]); return; }
+
+    if ((m = tag.match(/^(?:1?c|1c)&?[Hh]([0-9a-fA-F]+)&?$/))) {
+        state.primary = parseColour('&H' + m[1]); return;
+    }
+    if ((m = tag.match(/^3c&?[Hh]([0-9a-fA-F]+)&?$/))) {
+        state.outlineColour = parseColour('&H' + m[1]); return;
+    }
+    if ((m = tag.match(/^4c&?[Hh]([0-9a-fA-F]+)&?$/))) {
+        state.backColour = parseColour('&H' + m[1]); return;
+    }
+    if ((m = tag.match(/^(?:alpha|1a)&?[Hh]([0-9a-fA-F]+)&?$/i))) {
+        var a = parseAlpha(m[1]);
+        state.primary = withAlpha(state.primary, a); return;
+    }
+    if ((m = tag.match(/^3a&?[Hh]([0-9a-fA-F]+)&?$/i))) {
+        state.outlineColour = withAlpha(state.outlineColour, parseAlpha(m[1])); return;
+    }
+    if ((m = tag.match(/^4a&?[Hh]([0-9a-fA-F]+)&?$/i))) {
+        state.backColour = withAlpha(state.backColour, parseAlpha(m[1])); return;
+    }
+    // Everything else (\t, \clip, \k, \fr x/y, \org, \2c …) is ignored on purpose
+}
+
+function withAlpha(colour, a) {
+    return { r: colour.r, g: colour.g, b: colour.b, a: a };
+}
+
+// ─── Rendering ────────────────────────────────────────────────────────────────
+
+function escapeHtml(s) {
+    return String(s)
+        .replace(/&/g, '&amp;').replace(/</g, '&lt;')
+        .replace(/>/g, '&gt;').replace(/"/g, '&quot;');
+}
+
+/**
+ * Renderer draws events into an overlay element.
+ *
+ * The overlay must be positioned over the video's *displayed* rectangle; call
+ * resize(width, height) whenever that changes so PlayRes coordinates map onto
+ * real pixels.
+ */
+function Renderer(overlay) {
+    this.overlay = overlay;
+    this.track = null;
+    this.width = 0;
+    this.height = 0;
+    this.scale = 1;
+    this._lastKey = null;
+}
+
+Renderer.prototype.setTrack = function (track) {
+    this.track = track;
+    this._lastKey = null;
+    this.clear();
+};
+
+Renderer.prototype.resize = function (width, height) {
+    this.width = width;
+    this.height = height;
+    if (this.track && this.track.playResY > 0) {
+        // Uniform scale off the vertical axis, matching how libass maps a script
+        // onto a frame of a different size.
+        this.scale = height / this.track.playResY;
+    }
+    this._lastKey = null;   // force a redraw at the new geometry
+};
+
+Renderer.prototype.clear = function () {
+    if (this.overlay) { this.overlay.innerHTML = ''; }
+    this._lastKey = null;
+};
+
+Renderer.prototype.activeEvents = function (time) {
+    var out = [];
+    if (!this.track) { return out; }
+    var events = this.track.events;
+    for (var i = 0; i < events.length; i++) {
+        if (time >= events[i].start && time <= events[i].end) { out.push(events[i]); }
+    }
+    return out;
+};
+
+Renderer.prototype.setTime = function (time) {
+    if (!this.overlay || !this.track) { return; }
+
+    var active = this.activeEvents(time);
+
+    // Redrawing every frame would thrash the DOM; only rebuild when the set of
+    // visible events changes. Fades still need per-frame opacity, so events
+    // carrying one are excluded from the reuse check.
+    var hasFade = false;
+    var key = active.map(function (e) {
+        return e.start + '/' + e.end + '/' + e.style + '/' + e.text.length;
+    }).join('|');
+    for (var i = 0; i < active.length; i++) {
+        if (active[i].text.indexOf('\\fad') !== -1) { hasFade = true; break; }
+    }
+    if (!hasFade && key === this._lastKey) { return; }
+    this._lastKey = hasFade ? null : key;
+
+    var html = '';
+    for (var j = 0; j < active.length; j++) {
+        html += this.renderEvent(active[j], time, j);
+    }
+    this.overlay.innerHTML = html;
+};
+
+Renderer.prototype.renderEvent = function (ev, time, order) {
+    var track = this.track;
+    var style = track.styles[ev.style] || track.styles.Default;
+    var built = buildRuns(ev.text, style, track.styles);
+
+    // Vector drawings would render as a stream of coordinates; skip them.
+    if (built.block.drawing) { return ''; }
+
+    var body = '';
+    for (var i = 0; i < built.runs.length; i++) {
+        var run = built.runs[i];
+        if (run.lineBreak) { body += '<br>'; continue; }
+        if (!run.text) { continue; }
+        body += '<span style="' + this.runCss(run.state) + '">' + escapeHtml(run.text) + '</span>';
+    }
+    if (!body) { return ''; }
+
+    var align = built.block.align !== null ? built.block.align : style.alignment;
+    var opacity = this.fadeOpacity(built.block.fade, ev, time);
+    var box = this.boxCss(ev, style, align, built.block, opacity, order);
+
+    return '<div style="' + box + '">' + body + '</div>';
+};
+
+Renderer.prototype.fadeOpacity = function (fade, ev, time) {
+    if (!fade) { return 1; }
+    var inSec = (fade.inMs || 0) / 1000;
+    var outSec = (fade.outMs || 0) / 1000;
+    if (inSec > 0 && time < ev.start + inSec) {
+        return Math.max(0, Math.min(1, (time - ev.start) / inSec));
+    }
+    if (outSec > 0 && time > ev.end - outSec) {
+        return Math.max(0, Math.min(1, (ev.end - time) / outSec));
+    }
+    return 1;
+};
+
+// Position the text block. Alignment uses the numpad layout, so 1-3 is the
+// bottom row, 4-6 the middle and 7-9 the top.
+Renderer.prototype.boxCss = function (ev, style, align, block, opacity, order) {
+    var s = this.scale;
+    var horizontal = ((align - 1) % 3);   // 0 left, 1 centre, 2 right
+    var vertical = Math.floor((align - 1) / 3);   // 0 bottom, 1 middle, 2 top
+
+    var marginL = (ev.marginL || style.marginL) * s;
+    var marginR = (ev.marginR || style.marginR) * s;
+    var marginV = (ev.marginV || style.marginV) * s;
+
+    var css = 'position:absolute;';
+    css += 'text-align:' + ['left', 'center', 'right'][horizontal] + ';';
+    css += 'z-index:' + (10 + order) + ';';
+    if (opacity < 1) { css += 'opacity:' + opacity.toFixed(3) + ';'; }
+
+    if (block.pos) {
+        var x = block.pos.x * s;
+        var y = block.pos.y * s;
+        var tx = ['0', '-50%', '-100%'][horizontal];
+        var ty = ['-100%', '-50%', '0'][vertical];
+        css += 'left:' + x.toFixed(1) + 'px;top:' + y.toFixed(1) + 'px;';
+        css += 'transform:translate(' + tx + ',' + ty + ')';
+        css += block.rotate ? ' rotate(' + (-block.rotate) + 'deg);' : ';';
+        css += 'white-space:pre;';
+    } else {
+        css += 'left:' + marginL.toFixed(1) + 'px;right:' + marginR.toFixed(1) + 'px;';
+        if (vertical === 0)      { css += 'bottom:' + marginV.toFixed(1) + 'px;'; }
+        else if (vertical === 2) { css += 'top:' + marginV.toFixed(1) + 'px;'; }
+        else                     { css += 'top:50%;transform:translateY(-50%);'; }
+        if (block.rotate) { css += 'rotate:' + (-block.rotate) + 'deg;'; }
+    }
+    return css;
+};
+
+Renderer.prototype.runCss = function (st) {
+    var s = this.scale;
+    var css = '';
+    css += 'font-family:' + cssFontStack(st.fontname) + ';';
+    css += 'font-size:' + (st.fontsize * s).toFixed(2) + 'px;';
+    css += 'color:' + colourToCss(st.primary) + ';';
+    if (st.bold)      { css += 'font-weight:bold;'; }
+    if (st.italic)    { css += 'font-style:italic;'; }
+    if (st.underline || st.strikeout) {
+        css += 'text-decoration:' +
+            (st.underline ? 'underline ' : '') + (st.strikeout ? 'line-through' : '') + ';';
+    }
+    if (st.spacing)   { css += 'letter-spacing:' + (st.spacing * s).toFixed(2) + 'px;'; }
+
+    if (st.borderStyle === 3) {
+        // Opaque box instead of an outline
+        css += 'background-color:' + colourToCss(st.outlineColour) + ';';
+        css += 'padding:0.05em 0.2em;';
+    } else if (st.outline > 0) {
+        // paint-order keeps the stroke behind the glyph so thick outlines do not
+        // eat into the letterforms.
+        css += '-webkit-text-stroke:' + (st.outline * s).toFixed(2) + 'px ' + colourToCss(st.outlineColour) + ';';
+        css += 'paint-order:stroke fill;';
+    }
+    if (st.shadow > 0) {
+        var d = (st.shadow * s).toFixed(2);
+        css += 'text-shadow:' + d + 'px ' + d + 'px 0 ' + colourToCss(st.backColour) + ';';
+    }
+    return css;
+};
+
+// Quote the family name and keep a generic fallback so a missing embedded font
+// still renders readable text.
+//
+// Single quotes are required: these declarations are emitted into a
+// style="..." attribute, so a double-quoted family name would close the
+// attribute early and silently discard every property after it.
+function cssFontStack(name) {
+    var clean = String(name || '').replace(/^@/, '').replace(/['"\\<>]/g, '').trim();
+    if (!clean) { return 'sans-serif'; }
+    return "'" + clean + "', sans-serif";
+}
+
+// Font families referenced anywhere in the track, so the player knows which
+// attachments are worth downloading.
+function referencedFonts(track) {
+    var seen = {};
+    var out = [];
+    function add(name) {
+        var clean = String(name || '').replace(/^@/, '').trim();
+        if (clean && !seen[clean.toLowerCase()]) {
+            seen[clean.toLowerCase()] = true;
+            out.push(clean);
+        }
+    }
+    for (var key in track.styles) {
+        if (Object.prototype.hasOwnProperty.call(track.styles, key)) { add(track.styles[key].fontname); }
+    }
+    for (var i = 0; i < track.events.length; i++) {
+        var tags = track.events[i].text.match(/\\fn([^\\}]+)/g);
+        if (!tags) { continue; }
+        for (var j = 0; j < tags.length; j++) { add(tags[j].slice(3)); }
+    }
+    return out;
+}
+
+global.ASS = {
+    parse: parse,
+    Renderer: Renderer,
+    referencedFonts: referencedFonts,
+    // exposed for tests
+    _parseTime: parseTime,
+    _parseColour: parseColour,
+    _normaliseAlignment: normaliseAlignment,
+    _buildRuns: buildRuns
+};
+
+})(window);