支持 ASS 字幕原样渲染与字幕显示设置

This commit is contained in:
truewhile
2026-09-11 19:35:08 +08:00
parent e840cbd2c9
commit 0f06bdf929
19 changed files with 874 additions and 76 deletions
+1
View File
@@ -14,6 +14,7 @@ verify-cache/
verify-media/
verify-downloads/
.codex-*
.codex/
.tmp/
.tmp_*
.tmp-deploy-*
+1
View File
@@ -66,6 +66,7 @@ config.yaml
.tmp-live-backups/
.tmp-*
.codex-*
.codex/
downloads/
media/
*.pid
@@ -54,6 +54,7 @@ func registerAuthedMediaRoutes(authed *gin.RouterGroup, svc *service.Container)
authed.DELETE("/media/:id", middleware.AdminRequired(), deleteMediaHandler(svc))
authed.GET("/media/:id/subtitles", listSubtitlesHandler(svc))
authed.GET("/subtitles/:id", serveSubtitleHandler(svc))
authed.GET("/subtitles/:id/ass", serveASSSubtitleHandler(svc))
authed.POST("/media/:id/nfo", middleware.AdminRequired(), exportNFOHandler(svc))
authed.POST("/libraries/:id/nfo", middleware.AdminRequired(), exportLibraryNFOHandler(svc))
}
+16
View File
@@ -34,6 +34,22 @@ func listSubtitlesHandler(svc *service.Container) gin.HandlerFunc {
}
}
func serveASSSubtitleHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
path := c.Query("path")
if path == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "missing path"})
return
}
c.Header("Content-Type", "text/plain; charset=utf-8")
c.Header("Cache-Control", "no-cache, no-store, must-revalidate")
if err := svc.Subtitle.ServeASS(c.Request.Context(), c.Param("id"), path, c.Writer); err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
return
}
}
}
func serveSubtitleHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
path := c.Query("path")
+66 -2
View File
@@ -234,7 +234,7 @@ func discoverExternalSubtitleTracks(m *model.Media) []SubtitleTrack {
Path: filepath.Join(c, e.Name()),
Codec: codec,
Source: "external",
Delivery: "webvtt",
Delivery: subtitleDeliveryForCodec(codec),
})
}
}
@@ -297,6 +297,18 @@ func (s *SubtitleService) discoverEmbedded(ctx context.Context, media *model.Med
return subtitleTracksFromProbe(probe), nil
}
// subtitleDeliveryForCodec selects the browser rendering path for a text
// subtitle codec. ASS/SSA keep their original bytes and are rendered by
// libass in the web player; other text formats are converted to WebVTT.
func subtitleDeliveryForCodec(codec string) string {
switch strings.ToLower(strings.TrimSpace(codec)) {
case "ass", "ssa":
return "ass"
default:
return "webvtt"
}
}
func subtitleTracksFromProbe(probe embeddedSubtitleProbe) []SubtitleTrack {
tracks := make([]SubtitleTrack, 0, len(probe.Streams))
for _, stream := range probe.Streams {
@@ -314,7 +326,7 @@ func subtitleTracksFromProbe(probe embeddedSubtitleProbe) []SubtitleTrack {
} else if stream.Disposition.Default != 0 {
label += "(默认)"
}
delivery := "webvtt"
delivery := subtitleDeliveryForCodec(codec)
if imageSubtitleCodecs[codec] {
delivery = "burn"
}
@@ -464,6 +476,9 @@ func (s *SubtitleService) ServeRaw(ctx context.Context, mediaID, sub string, w i
if err != nil || m == nil {
return errors.New("media not found")
}
if strings.HasPrefix(sub, "embedded:") {
return errors.New("embedded subtitle is not available in raw mode")
}
abs, err := filepath.Abs(sub)
if err != nil {
return err
@@ -472,6 +487,9 @@ func (s *SubtitleService) ServeRaw(ctx context.Context, mediaID, sub string, w i
if !pathWithin(abs, mediaDir) {
return fmt.Errorf("path escape")
}
if _, ok := extToCodec[strings.ToLower(filepath.Ext(abs))]; !ok {
return errors.New("unsupported subtitle format")
}
f, err := os.Open(abs) // #nosec G304 -- abs is constrained to the media file directory with pathWithin.
if err != nil {
return err
@@ -480,3 +498,49 @@ func (s *SubtitleService) ServeRaw(ctx context.Context, mediaID, sub string, w i
_, err = io.Copy(w, f)
return err
}
// ServeASS returns an ASS/SSA stream suitable for libass-wasm. External ASS
// files are sent unchanged; embedded ASS/SSA tracks are remuxed to ASS by
// ffmpeg. This keeps fonts, positioning and typesetting data available to the
// browser renderer instead of flattening the track through assToVTT first.
func (s *SubtitleService) ServeASS(ctx context.Context, mediaID, sub string, w io.Writer) error {
m, err := s.repo.Media.FindByID(ctx, mediaID)
if err != nil || m == nil {
return errors.New("media not found")
}
if strings.HasPrefix(sub, "embedded:") {
index, err := strconv.Atoi(strings.TrimPrefix(sub, "embedded:"))
if err != nil || index < 0 {
return errors.New("invalid embedded subtitle")
}
return s.serveEmbeddedASS(ctx, m, index, w)
}
switch strings.ToLower(filepath.Ext(sub)) {
case ".ass", ".ssa":
return s.ServeRaw(ctx, mediaID, sub, w)
default:
return errors.New("subtitle is not ASS/SSA")
}
}
func (s *SubtitleService) serveEmbeddedASS(ctx context.Context, media *model.Media, streamIndex int, w io.Writer) error {
input, err := s.resolveInput(ctx, media)
if err != nil {
return err
}
bin, err := resolveLocalExecutable(s.cfg.App.FFmpegPath, "ffmpeg")
if err != nil {
return err
}
args := []string{"-hide_banner", "-loglevel", "error"}
args = append(args, ffmpegHTTPInputArgs(input)...)
args = append(args, "-i", input.Source, "-map", "0:"+strconv.Itoa(streamIndex), "-c:s", "ass", "-f", "ass", "-")
cmd := exec.CommandContext(ctx, bin, args...) // #nosec G204 -- executable is resolved locally and arguments do not use a shell.
cmd.Stdout = w
var stderr strings.Builder
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
return fmt.Errorf("extract embedded ASS subtitle: %w: %s", err, strings.TrimSpace(stderr.String()))
}
return nil
}
+26 -1
View File
@@ -100,7 +100,7 @@ func TestEmbeddedSubtitleProbeClassifiesTextAndBitmapTracks(t *testing.T) {
if len(tracks) != 2 {
t.Fatalf("len(tracks) = %d, want 2", len(tracks))
}
if tracks[0].Delivery != "webvtt" || tracks[0].Path != "embedded:2" {
if tracks[0].Delivery != "ass" || tracks[0].Path != "embedded:2" {
t.Fatalf("text track = %#v", tracks[0])
}
if tracks[1].Delivery != "burn" || tracks[1].StreamIndex != 4 {
@@ -108,6 +108,18 @@ func TestEmbeddedSubtitleProbeClassifiesTextAndBitmapTracks(t *testing.T) {
}
}
func TestSubtitleDeliveryClassifiesASSForLibass(t *testing.T) {
if got := subtitleDeliveryForCodec("ass"); got != "ass" {
t.Fatalf("ass delivery = %q, want ass", got)
}
if got := subtitleDeliveryForCodec("ssa"); got != "ass" {
t.Fatalf("ssa delivery = %q, want ass", got)
}
if got := subtitleDeliveryForCodec("subrip"); got != "webvtt" {
t.Fatalf("subrip delivery = %q, want webvtt", got)
}
}
func TestNormaliseTimecode(t *testing.T) {
cases := map[string]string{
"0:00:01": "00:00:01",
@@ -176,4 +188,17 @@ func TestSubtitleServeRawWritesSourceBytes(t *testing.T) {
if got := buf.String(); got != raw {
t.Fatalf("ServeRaw returned %q, want raw %q", got, raw)
}
buf.Reset()
if err := svc.ServeASS(t.Context(), media.ID, subPath, &buf); err != nil {
t.Fatal(err)
}
if got := buf.String(); got != raw {
t.Fatalf("ServeASS returned %q, want raw ASS %q", got, raw)
}
buf.Reset()
if err := svc.ServeRaw(t.Context(), media.ID, videoPath, &buf); err == nil {
t.Fatal("ServeRaw accepted a non-subtitle file")
}
}
+38
View File
@@ -13,6 +13,7 @@
"danmu": "^0.20.0",
"framer-motion": "^12.38.0",
"hls.js": "^1.5.11",
"jassub": "^2.5.16",
"lucide-react": "^0.379.0",
"opencc-js": "^1.4.2",
"qrcode": "^1.5.4",
@@ -1753,6 +1754,12 @@
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
}
},
"node_modules/abslink": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/abslink/-/abslink-1.2.2.tgz",
"integrity": "sha512-HCOItvzORPjMzddCmEpbH8+W1VM3yjH+CR7DLtxlxFKX/jz5twJbm7KLfblO7eKLnXINLah8Hk2ZAJqxXeSkOg==",
"license": "Apache-2.0"
},
"node_modules/acorn": {
"version": "8.16.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
@@ -3108,6 +3115,18 @@
"dev": true,
"license": "ISC"
},
"node_modules/jassub": {
"version": "2.5.16",
"resolved": "https://registry.npmjs.org/jassub/-/jassub-2.5.16.tgz",
"integrity": "sha512-n4vjXfraf3o8k3/5TK6DhCYVcbQ/VqhVOgwqLd3l0xfMtsN4fpE/hPq2hPaGagBTOtJS9yHwRoRp3mmchQ9XVg==",
"license": "LGPL-2.1-or-later AND (FTL OR GPL-2.0-or-later) AND MIT AND MIT-Modern-Variant AND ISC AND NTP AND Zlib AND BSL-1.0",
"dependencies": {
"abslink": "^1.2.2",
"lfa-ponyfill": "^1.1.0",
"rvfc-polyfill": "^1.0.8",
"throughput": "^1.0.2"
}
},
"node_modules/jiti": {
"version": "1.21.7",
"resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz",
@@ -3195,6 +3214,13 @@
"node": ">= 0.8.0"
}
},
"node_modules/lfa-ponyfill": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/lfa-ponyfill/-/lfa-ponyfill-1.1.1.tgz",
"integrity": "sha512-Had4tsGEHtIj58MRtf6SlubNDltxinxMSdxIn1GdwrWbye8TD+pZ+D6qKETnJ9+vQOfG9q/Ds4WUPskhyAMp5Q==",
"hasInstallScript": true,
"license": "MIT"
},
"node_modules/lilconfig": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz",
@@ -4032,6 +4058,12 @@
"queue-microtask": "^1.2.2"
}
},
"node_modules/rvfc-polyfill": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/rvfc-polyfill/-/rvfc-polyfill-1.0.8.tgz",
"integrity": "sha512-uA+0wwTkZ4OT8v45pfDfH+7Yq8mY6MvNngiF5Sq6VBgjJsvsfgt7Q18cyZqZjfAhW9rhkgXPX0cW0R9uw7yElA==",
"license": "GPL-3.0"
},
"node_modules/scheduler": {
"version": "0.23.2",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
@@ -4219,6 +4251,12 @@
"node": ">=0.8"
}
},
"node_modules/throughput": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/throughput/-/throughput-1.0.2.tgz",
"integrity": "sha512-jvK1ZXuhsggjb3qYQjMiU/AVYYiTeqT5thWvYR2yuy2LGM84P5MSSyAinwHahGsdBYKR9m9HncVR/3f3nFKkxg==",
"license": "MIT"
},
"node_modules/tinyglobby": {
"version": "0.2.16",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz",
+1
View File
@@ -15,6 +15,7 @@
"danmu": "^0.20.0",
"framer-motion": "^12.38.0",
"hls.js": "^1.5.11",
"jassub": "^2.5.16",
"lucide-react": "^0.379.0",
"opencc-js": "^1.4.2",
"qrcode": "^1.5.4",
+8 -1
View File
@@ -8,7 +8,7 @@ export interface SubtitleTrack {
url: string
codec: string
source: 'external' | 'embedded'
delivery: 'webvtt' | 'burn'
delivery: 'webvtt' | 'ass' | 'burn'
stream_index?: number
}
@@ -26,4 +26,11 @@ export const subtitlesAPI = {
path,
)}&token=${encodeURIComponent(token)}`
},
assUrl: (mediaId: string, path: string) => {
const token = useAuthStore.getState().token ?? ''
return `/api/subtitles/${encodeURIComponent(mediaId)}/ass?path=${encodeURIComponent(
path,
)}&token=${encodeURIComponent(token)}`
},
}
@@ -0,0 +1,92 @@
This Font Software is licensed under the SIL Open Font License,
Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font
creation efforts of academic and linguistic communities, and to
provide a free and open framework in which fonts may be shared and
improved in partnership with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply to
any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software
components as distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to,
deleting, or substituting -- in part or in whole -- any of the
components of the Original Version, by changing formats or by porting
the Font Software to a new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed,
modify, redistribute, and sell modified and unmodified copies of the
Font Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components, in
Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the
corresponding Copyright Holder. This restriction only applies to the
primary font name as presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created using
the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
Binary file not shown.
+107
View File
@@ -0,0 +1,107 @@
import { useEffect, useRef } from 'react'
import JASSUB from 'jassub'
import jassubWorkerUrl from 'jassub/dist/worker/worker.js?worker&url'
import jassubWasmUrl from 'jassub/dist/wasm/jassub-worker.wasm?url'
import jassubModernWasmUrl from 'jassub/dist/wasm/jassub-worker-modern.wasm?url'
import notoSansSCUrl from '../assets/fonts/noto-sans-sc-regular.woff2?url'
import {
loadSubtitleChineseConverter,
type SubtitleChineseMode,
} from '../utils/subtitleChinese'
import { convertASSContent } from '../utils/subtitleASS'
type AssSubtitleStageProps = {
videoRef: React.RefObject<HTMLVideoElement>
source: string
timeOffset?: number
chineseMode?: SubtitleChineseMode
onReady?: () => void
onError?: (error: unknown) => void
}
/**
* libass-wasm subtitle layer for external and embedded ASS/SSA tracks. JASSUB
* owns its canvas and attaches it next to the video, so this component itself
* renders no visible DOM nodes.
*/
export function AssSubtitleStage({
videoRef,
source,
timeOffset = 0,
chineseMode = 'original',
onReady,
onError,
}: AssSubtitleStageProps) {
const instanceRef = useRef<JASSUB | null>(null)
const timeOffsetRef = useRef(timeOffset)
const callbacksRef = useRef({ onReady, onError })
useEffect(() => {
callbacksRef.current = { onReady, onError }
}, [onReady, onError])
useEffect(() => {
const video = videoRef.current
if (!video || !source) return
let cancelled = false
let instance: JASSUB | null = null
const controller = new AbortController()
const load = async () => {
try {
const [response, converter] = await Promise.all([
fetch(source, { signal: controller.signal }),
loadSubtitleChineseConverter(chineseMode).catch(() => (text: string) => text),
])
if (!response.ok) throw new Error(`subtitle request failed: ${response.status}`)
const body = await response.text()
if (!body.trim()) throw new Error('empty subtitle payload')
if (cancelled) return
instance = new JASSUB({
video,
subContent: convertASSContent(body, converter),
timeOffset: timeOffsetRef.current,
workerUrl: jassubWorkerUrl,
wasmUrl: jassubWasmUrl,
modernWasmUrl: jassubModernWasmUrl,
fonts: [notoSansSCUrl],
defaultFont: 'Noto Sans SC',
queryFonts: 'local',
prescaleFactor: 1,
})
instanceRef.current = instance
await instance.ready
if (cancelled) {
void instance.destroy().catch(() => undefined)
return
}
callbacksRef.current.onReady?.()
} catch (error: unknown) {
if (cancelled) return
if (error instanceof DOMException && error.name === 'AbortError') return
callbacksRef.current.onError?.(error)
}
}
void load()
return () => {
cancelled = true
controller.abort()
instanceRef.current = null
if (instance) void instance.destroy().catch(() => undefined)
}
// timeOffset is applied live below. Recreating the full libass worker on a
// seek offset change would be unnecessarily expensive.
}, [source, chineseMode, videoRef])
useEffect(() => {
timeOffsetRef.current = timeOffset
if (instanceRef.current) instanceRef.current.timeOffset = timeOffset
}, [timeOffset])
return null
}
+66 -3
View File
@@ -16,6 +16,12 @@ import {
} from 'lucide-react'
import type { SubtitleTrack } from '../api/subtitles'
import type { SubtitleChineseMode } from '../utils/subtitleChinese'
import {
SUBTITLE_POSITION_OPTIONS,
SUBTITLE_STYLE_OPTIONS,
type SubtitlePosition,
type SubtitleStylePreset,
} from '../utils/subtitleDisplay'
// PlayerControls — custom bottom control bar replacing the native <video
// controls> (which cannot host custom buttons). The danmaku toggle sits right
@@ -39,6 +45,10 @@ type PlayerControlsProps = {
onSelectSubtitle: (index: number) => void
subtitleChineseMode: SubtitleChineseMode
onSubtitleChineseModeChange: (mode: SubtitleChineseMode) => void
subtitlePosition: SubtitlePosition
onSubtitlePositionChange: (position: SubtitlePosition) => void
subtitleStyle: SubtitleStylePreset
onSubtitleStyleChange: (style: SubtitleStylePreset) => void
danmakuOpen: boolean
danmakuEnabled: boolean
onToggleDanmaku: () => void
@@ -68,6 +78,10 @@ export function PlayerControls({
onSelectSubtitle,
subtitleChineseMode,
onSubtitleChineseModeChange,
subtitlePosition,
onSubtitlePositionChange,
subtitleStyle,
onSubtitleStyleChange,
danmakuOpen,
danmakuEnabled,
onToggleDanmaku,
@@ -361,7 +375,10 @@ export function PlayerControls({
const displayTime = isScrubbing && scrubValue !== null ? scrubValue : currentTime
const selectedSubtitle = subtitleIndex >= 0 ? subs[subtitleIndex] : undefined
const canConvertSelectedSubtitle =
selectedSubtitle?.source === 'external' && selectedSubtitle.delivery === 'webvtt'
selectedSubtitle?.source === 'external' &&
(selectedSubtitle.delivery === 'webvtt' || selectedSubtitle.delivery === 'ass')
const canAdjustSelectedSubtitle = selectedSubtitle?.delivery === 'webvtt'
const usesOriginalASS = selectedSubtitle?.delivery === 'ass'
return (
<div
@@ -455,7 +472,7 @@ export function PlayerControls({
)}
</button>
{subtitleMenuOpen && (
<div className="absolute bottom-11 right-0 z-30 min-w-44 rounded-xl border border-white/15 bg-black/85 p-1 shadow-2xl backdrop-blur">
<div className="absolute bottom-11 right-0 z-30 max-h-[70vh] min-w-44 overflow-y-auto rounded-xl border border-white/15 bg-black/85 p-1 shadow-2xl backdrop-blur">
<button
type="button"
onClick={() => {
@@ -481,10 +498,56 @@ export function PlayerControls({
}`}
title={track.label || track.lang}
>
<span className="truncate">{track.label || track.lang || `字幕 ${index + 1}`}</span>
<span className="truncate">
{track.label || track.lang || `字幕 ${index + 1}`}
{track.delivery === 'ass' ? ' · ASS' : ''}
</span>
{subtitleIndex === index && <span className="ml-auto text-rose-400">●</span>}
</button>
))}
{canAdjustSelectedSubtitle && (
<div className="mt-1 border-t border-white/10 pt-1">
<p className="px-3 py-1 text-[10px] text-white/45">字幕位置</p>
<div className="flex flex-wrap gap-1 px-2 pb-1">
{SUBTITLE_POSITION_OPTIONS.map(([value, label]) => (
<button
key={value}
type="button"
onClick={() => onSubtitlePositionChange(value)}
className={`rounded-md px-2 py-1 text-[10px] transition ${
subtitlePosition === value
? 'bg-rose-500/20 text-rose-300'
: 'bg-white/5 text-white/70 hover:bg-white/10'
}`}
>
{label}
</button>
))}
</div>
<p className="px-3 py-1 text-[10px] text-white/45">字幕样式</p>
<div className="flex flex-wrap gap-1 px-2 pb-1">
{SUBTITLE_STYLE_OPTIONS.map(([value, label]) => (
<button
key={value}
type="button"
onClick={() => onSubtitleStyleChange(value)}
className={`rounded-md px-2 py-1 text-[10px] transition ${
subtitleStyle === value
? 'bg-rose-500/20 text-rose-300'
: 'bg-white/5 text-white/70 hover:bg-white/10'
}`}
>
{label}
</button>
))}
</div>
</div>
)}
{usesOriginalASS && (
<p className="mx-2 mt-1 border-t border-white/10 px-1 pt-2 text-[10px] leading-relaxed text-white/45">
ASS/SSA 使用字幕文件自带的样式与位置,不做统一覆盖。
</p>
)}
{canConvertSelectedSubtitle && (
<div className="mt-1 border-t border-white/10 pt-1">
<p className="px-3 py-1 text-[10px] text-white/45">外挂字幕简繁转换</p>
+24
View File
@@ -18,6 +18,14 @@ import {
normalizeSubtitleChineseMode,
type SubtitleChineseMode,
} from '../utils/subtitleChinese'
import {
loadSubtitlePosition,
loadSubtitleStyle,
saveSubtitlePosition,
saveSubtitleStyle,
type SubtitlePosition,
type SubtitleStylePreset,
} from '../utils/subtitleDisplay'
import { pickPlayerMode, needsTranscodeForBrowser, isDirectStreamMedia, type PlayerMode } from './playerPageModel'
import { classifyDirectPlayError } from './directPlayError'
import { apiErrorMessage } from './StrmManagePage'
@@ -63,6 +71,8 @@ export function PlayerPage() {
useState<SubtitleChineseMode>(() =>
normalizeSubtitleChineseMode(authUser?.subtitle_chinese_mode),
)
const [subtitlePosition, setSubtitlePosition] = useState<SubtitlePosition>(loadSubtitlePosition)
const [subtitleStyle, setSubtitleStyle] = useState<SubtitleStylePreset>(loadSubtitleStyle)
const persistedSubtitleChineseModeRef = useRef(subtitleChineseMode)
const subtitlePreferenceTouchedRef = useRef(false)
const subtitlePreferenceSaveQueueRef = useRef<Promise<void>>(Promise.resolve())
@@ -878,6 +888,16 @@ export function PlayerPage() {
fallbackTimerRef.current = setTimeout(fallbackDirectPlay, 1500)
}, [clearFallbackTimer, directOnly, hlsUnavailable, mediaId, mode, setPlaybackMode])
const changeSubtitlePosition = useCallback((nextPosition: SubtitlePosition) => {
setSubtitlePosition(nextPosition)
saveSubtitlePosition(nextPosition)
}, [])
const changeSubtitleStyle = useCallback((nextStyle: SubtitleStylePreset) => {
setSubtitleStyle(nextStyle)
saveSubtitleStyle(nextStyle)
}, [])
const danmakuAutoTitle =
danmakuInfo?.animeTitle ||
media?.original_name?.trim() ||
@@ -910,6 +930,10 @@ export function PlayerPage() {
onSelectSubtitle={selectSubtitle}
subtitleChineseMode={subtitleChineseMode}
onSubtitleChineseModeChange={changeSubtitleChineseMode}
subtitlePosition={subtitlePosition}
onSubtitlePositionChange={changeSubtitlePosition}
subtitleStyle={subtitleStyle}
onSubtitleStyleChange={changeSubtitleStyle}
videoRef={ref}
onVideoError={handleVideoError}
danmakuEnabled={danmakuEnabled}
+197 -69
View File
@@ -1,5 +1,5 @@
import { useEffect, useRef, useState } from 'react'
import type { PointerEvent, ReactNode, RefObject } from 'react'
import type { CSSProperties, PointerEvent, ReactNode, RefObject } from 'react'
import { subtitlesAPI, type SubtitleTrack } from '../api/subtitles'
import { type DanmakuAnime, type DanmakuLoadedInfo } from '../api/danmaku'
@@ -8,55 +8,126 @@ import {
loadSubtitleChineseConverter,
type SubtitleChineseMode,
} from '../utils/subtitleChinese'
import { AssSubtitleStage } from '../components/AssSubtitleStage'
import { DanmakuStage } from '../components/DanmakuStage'
import { PlayerControls } from '../components/PlayerControls'
import {
subtitleTextStyle,
type SubtitlePosition,
type SubtitleStylePreset,
} from '../utils/subtitleDisplay'
import { parseWebVTTCues, type SubtitleCue } from '../utils/subtitleVTT'
type SubtitleCue = {
startTime: number
endTime: number
type SubtitleRenderGroup = {
key: string
text: string
style: CSSProperties
}
function parseVTTTimestamp(value: string): number {
const parts = value.trim().replace(',', '.').split(':')
if (parts.length !== 2 && parts.length !== 3) return Number.NaN
const seconds = Number(parts.pop())
const minutes = Number(parts.pop())
const hours = parts.length > 0 ? Number(parts.pop()) : 0
if (![hours, minutes, seconds].every(Number.isFinite)) return Number.NaN
return hours * 3600 + minutes * 60 + seconds
function uniqueSubtitleCues(cues: SubtitleCue[]): SubtitleCue[] {
const seen = new Set<string>()
const unique: SubtitleCue[] = []
for (const cue of cues) {
const key = `${cue.startTime}\u0000${cue.endTime}\u0000${JSON.stringify(cue.settings)}\u0000${cue.text}`
if (seen.has(key)) continue
seen.add(key)
unique.push(cue)
}
// WebVTT can contain more than two simultaneous tracks after ASS conversion.
// Keep the last two visual blocks, matching the previous bilingual behavior.
return unique.slice(-2)
}
function parseWebVTTCues(body: string): SubtitleCue[] {
const blocks = body
.replace(/^\uFEFF/, '')
.replace(/\r\n?/g, '\n')
.split(/\n{2,}/)
const cues: SubtitleCue[] = []
function parsePercent(value: string | undefined): number | null {
if (!value || !value.endsWith('%')) return null
const parsed = Number.parseFloat(value)
return Number.isFinite(parsed) ? Math.min(100, Math.max(0, parsed)) : null
}
for (const block of blocks) {
const lines = block.split('\n')
const timingIndex = lines.findIndex((line) => line.includes('-->'))
if (timingIndex < 0) continue
function cueLineBottom(value: string | undefined): number | null {
const percent = parsePercent(value)
if (percent !== null) return Math.min(92, Math.max(2, 100 - percent))
if (value === undefined) return null
const line = Number.parseFloat(value)
if (!Number.isFinite(line)) return null
if (line < 0) return Math.min(92, Math.max(2, 6 - Math.abs(line) * 2))
return Math.min(92, Math.max(2, 7 + line * 8))
}
const [rawStart, rawEnd] = lines[timingIndex].split('-->', 2)
const startTime = parseVTTTimestamp(rawStart)
const endTime = parseVTTTimestamp(rawEnd.trim().split(/\s+/, 1)[0])
const text = lines.slice(timingIndex + 1).join('\n').trim()
if (Number.isFinite(startTime) && Number.isFinite(endTime) && endTime >= startTime && text) {
cues.push({ startTime, endTime, text })
}
function cueTextAlign(cue: SubtitleCue): CSSProperties['textAlign'] {
const align = cue.settings.align
if (align === 'left' || align === 'start') return 'left'
if (align === 'right' || align === 'end') return 'right'
return 'center'
}
function cuePlacementStyle(cue: SubtitleCue, position: SubtitlePosition): CSSProperties {
const style: CSSProperties = {
maxWidth: `${Math.min(cue.settings.size ?? 92, 94)}%`,
textAlign: cueTextAlign(cue),
}
return cues
if (cue.settings.vertical) {
style.writingMode = cue.settings.vertical === 'lr' ? 'vertical-lr' : 'vertical-rl'
}
if (position !== 'auto') {
style.left = '50%'
style.textAlign = 'center'
if (position === 'bottom') {
style.bottom = '5%'
style.transform = 'translateX(-50%)'
} else if (position === 'lower') {
style.bottom = '13%'
style.transform = 'translateX(-50%)'
} else if (position === 'middle') {
style.top = '50%'
style.transform = 'translate(-50%, -50%)'
} else {
style.top = '7%'
style.transform = 'translateX(-50%)'
}
return style
}
const explicitPosition = cue.settings.position
const align = cue.settings.align
if (explicitPosition !== undefined) {
if (align === 'end' || align === 'right') {
style.right = `${100 - explicitPosition}%`
} else {
style.left = `${explicitPosition}%`
if (align !== 'start' && align !== 'left') style.transform = 'translateX(-50%)'
}
} else {
style.left = '50%'
style.transform = 'translateX(-50%)'
}
const bottom = cueLineBottom(cue.settings.line)
if (bottom !== null) style.bottom = `${bottom}%`
else style.bottom = '7%'
return style
}
function uniqueSubtitleTexts(texts: string[]): string[] {
const unique = [...new Set(texts.map((text) => text.trim()).filter(Boolean))]
// ponytail: WebVTT cannot preserve ASS layers/positions. Keep at most two
// simultaneous blocks for bilingual subtitles; use libass if full ASS
// typesetting support is added later.
return unique.slice(-2)
function buildSubtitleRenderGroups(
cues: SubtitleCue[],
position: SubtitlePosition,
): SubtitleRenderGroup[] {
const groups = new Map<string, SubtitleRenderGroup>()
for (const cue of cues) {
const style = cuePlacementStyle(cue, position)
const key = JSON.stringify(style)
const existing = groups.get(key)
if (existing) {
if (!existing.text.split('\n').includes(cue.text)) {
existing.text += `\n${cue.text}`
}
continue
}
groups.set(key, { key, text: cue.text, style })
}
return [...groups.values()]
}
type PlayerVideoStageProps = {
@@ -70,6 +141,10 @@ type PlayerVideoStageProps = {
onSelectSubtitle: (index: number) => void
subtitleChineseMode: SubtitleChineseMode
onSubtitleChineseModeChange: (mode: SubtitleChineseMode) => void
subtitlePosition: SubtitlePosition
onSubtitlePositionChange: (position: SubtitlePosition) => void
subtitleStyle: SubtitleStylePreset
onSubtitleStyleChange: (style: SubtitleStylePreset) => void
videoRef: RefObject<HTMLVideoElement>
onVideoError: () => void
danmakuEnabled: boolean
@@ -110,6 +185,10 @@ export function PlayerVideoStage({
onSelectSubtitle,
subtitleChineseMode,
onSubtitleChineseModeChange,
subtitlePosition,
onSubtitlePositionChange,
subtitleStyle,
onSubtitleStyleChange,
videoRef,
onVideoError,
danmakuEnabled,
@@ -144,7 +223,7 @@ export function PlayerVideoStage({
const [controlsVisible, setControlsVisible] = useState(true)
const revealControlsOnlyRef = useRef(false)
// 当前展示的字幕文本(由自定义字幕层渲染,100% 透明无黑框)
const [activeCueText, setActiveCueText] = useState<string>('')
const [activeCues, setActiveCues] = useState<SubtitleCue[]>([])
const [subtitleChineseConverter, setSubtitleChineseConverter] =
useState<(text: string) => string>(() => (text: string) => text)
// 独立保存完整 WebVTT 时间轴。HLS seek 会替换媒体源,Chromium 此时可能清空
@@ -155,6 +234,14 @@ export function PlayerVideoStage({
} | null>(null)
// 直连 302 尚未完成时插入 <track> 会中断加载并误报播放失败;等 canplay 再挂。
const [tracksArmed, setTracksArmed] = useState(false)
// libass/WASM is optional at runtime. If it fails, ASS tracks fall back to
// the server's WebVTT conversion so playback still has visible subtitles.
const [assFallbackPath, setAssFallbackPath] = useState<string | null>(null)
const activeSubtitleTrack = subtitleIndex >= 0 ? subs[subtitleIndex] : undefined
useEffect(() => {
setAssFallbackPath(null)
}, [media?.id, activeSubtitleTrack?.path, subs])
useEffect(() => {
const selectedTrack = subs[subtitleIndex]
@@ -201,7 +288,10 @@ export function PlayerVideoStage({
useEffect(() => {
const selectedTrack = subs[subtitleIndex]
if (!media || subtitleIndex < 0 || !selectedTrack || selectedTrack.delivery !== 'webvtt') {
const useWebVTT =
selectedTrack?.delivery === 'webvtt' ||
(selectedTrack?.delivery === 'ass' && assFallbackPath === selectedTrack.path)
if (!media || subtitleIndex < 0 || !selectedTrack || !useWebVTT) {
setSubtitleTimeline(null)
return
}
@@ -227,7 +317,7 @@ export function PlayerVideoStage({
})
return () => controller.abort()
}, [media, subs, subtitleIndex])
}, [assFallbackPath, media, subs, subtitleIndex])
// 监听舞台容器的真实尺寸(响应窗口大小调整和全屏切换)
useEffect(() => {
@@ -303,21 +393,22 @@ export function PlayerVideoStage({
subs.length === 0 ||
subtitleIndex < 0 ||
!selectedTrack ||
selectedTrack.delivery === 'burn'
(selectedTrack.delivery !== 'webvtt' &&
!(selectedTrack.delivery === 'ass' && assFallbackPath === selectedTrack.path))
) {
setActiveCueText('')
setActiveCues([])
return
}
const updateCue = () => {
const absoluteTime = video.currentTime + (streamOffset ?? 0)
if (subtitleTimeline?.path === selectedTrack.path) {
const texts = uniqueSubtitleTexts(
subtitleTimeline.cues
.filter((cue) => absoluteTime >= cue.startTime && absoluteTime <= cue.endTime)
.map((cue) => cue.text),
)
setActiveCueText(subtitleChineseConverter(texts.join('\n')))
const cues = uniqueSubtitleCues(
subtitleTimeline.cues.filter(
(cue) => absoluteTime >= cue.startTime && absoluteTime <= cue.endTime,
),
).map((cue) => ({ ...cue, text: subtitleChineseConverter(cue.text) }))
setActiveCues(cues)
return
}
@@ -326,17 +417,19 @@ export function PlayerVideoStage({
)
const tt = selectedEl?.track
if (!tt) {
setActiveCueText('')
setActiveCues([])
return
}
// 优先从浏览器 activeCues 中取当前文本;若浏览器在 hidden 模式下延迟触发 cuechange,
// 则从 tt.cues 中根据 video.currentTime 实时匹配当前字幕,确保初次加载无感立即可见。
const texts: string[] = []
const cues: SubtitleCue[] = []
if ((!streamOffset || streamOffset <= 0.05) && tt.activeCues && tt.activeCues.length > 0) {
for (let i = 0; i < tt.activeCues.length; i++) {
const cue = tt.activeCues[i] as VTTCue
if (cue && cue.text) texts.push(cue.text)
if (cue && cue.text) {
cues.push({ startTime: cue.startTime, endTime: cue.endTime, text: cue.text, settings: {} })
}
}
} else if (tt.cues && tt.cues.length > 0) {
for (let i = 0; i < tt.cues.length; i++) {
@@ -347,11 +440,16 @@ export function PlayerVideoStage({
absoluteTime <= cue.endTime &&
cue.text
) {
texts.push(cue.text)
cues.push({ startTime: cue.startTime, endTime: cue.endTime, text: cue.text, settings: {} })
}
}
}
setActiveCueText(subtitleChineseConverter(uniqueSubtitleTexts(texts).join('\n')))
setActiveCues(
uniqueSubtitleCues(cues).map((cue) => ({
...cue,
text: subtitleChineseConverter(cue.text),
})),
)
}
const apply = () => {
@@ -415,6 +513,7 @@ export function PlayerVideoStage({
subtitleTimeline,
tracksArmed,
subtitleChineseConverter,
assFallbackPath,
])
// 根据视频画面宽高比与舞台宽高比,确定视频在哪个轴向撑满 100%
@@ -423,6 +522,7 @@ export function PlayerVideoStage({
? videoRatio > stageRect.width / stageRect.height
: true
const assTrack = subtitleIndex >= 0 ? subs[subtitleIndex] : undefined
const wrapperStyle = videoRatio
? {
aspectRatio: `${videoRatio}`,
@@ -459,8 +559,11 @@ export function PlayerVideoStage({
onError={onVideoError}
>
{tracksArmed &&
subs.map((track, index) =>
track.delivery === 'burn' ? null : (
subs.map((track, index) => {
const renderAsFallback =
track.delivery === 'ass' && assFallbackPath === track.path
if (track.delivery !== 'webvtt' && !renderAsFallback) return null
return (
<track
key={track.path}
data-subtitle-index={index}
@@ -469,8 +572,8 @@ export function PlayerVideoStage({
srcLang={track.lang}
label={track.label || track.lang}
/>
),
)}
)
})}
</video>
<DanmakuStage
key={media.id}
@@ -486,19 +589,40 @@ export function PlayerVideoStage({
onLoaded={onDanmakuLoaded}
onCandidates={onDanmakuCandidates}
/>
{/* 自定义沉浸式字幕层:纯透明背景 + 柔和阴影,完全消除浏览器原生黑框 */}
{activeCueText ? (
<div className="pointer-events-none absolute inset-x-0 bottom-4 sm:bottom-6 md:bottom-8 z-10 flex justify-center text-center px-4">
<span
className="inline-block max-w-[92%] whitespace-pre-line text-center font-sans font-medium text-white text-base sm:text-lg md:text-xl lg:text-2xl select-none"
style={{
textShadow:
'0 1px 3px rgba(0, 0, 0, 0.95), 0 0 8px rgba(0, 0, 0, 0.85), 0 0 16px rgba(0, 0, 0, 0.65)',
lineHeight: 1.35,
}}
>
{activeCueText}
</span>
{tracksArmed && media && assTrack && assFallbackPath !== assTrack.path ? (
<AssSubtitleStage
key={`${media.id}:${assTrack.path}`}
videoRef={videoRef}
source={subtitlesAPI.assUrl(media.id, assTrack.path)}
timeOffset={streamOffset}
chineseMode={assTrack.source === 'external' ? subtitleChineseMode : 'original'}
onError={(error) => {
console.warn('libass subtitle rendering failed; falling back to WebVTT', error)
setAssFallbackPath(assTrack.path)
}}
/>
) : null}
{/* SRT/VTT 自定义字幕层:按 cue 位置对齐,并支持用户样式覆盖。 */}
{activeCues.length > 0 ? (
<div className="pointer-events-none absolute inset-0 z-10">
{buildSubtitleRenderGroups(activeCues, subtitlePosition).map((group) => (
<div
key={group.key}
className="absolute flex px-4"
style={group.style}
>
<span
className="inline-block whitespace-pre-line font-sans text-white select-none"
style={{
...subtitleTextStyle(subtitleStyle),
fontSize: 'clamp(16px, 2.1vw, 30px)',
lineHeight: 1.35,
}}
>
{group.text}
</span>
</div>
))}
</div>
) : null}
</div>
@@ -511,6 +635,10 @@ export function PlayerVideoStage({
onSelectSubtitle={onSelectSubtitle}
subtitleChineseMode={subtitleChineseMode}
onSubtitleChineseModeChange={onSubtitleChineseModeChange}
subtitlePosition={subtitlePosition}
onSubtitlePositionChange={onSubtitlePositionChange}
subtitleStyle={subtitleStyle}
onSubtitleStyleChange={onSubtitleStyleChange}
danmakuOpen={danmakuOpen}
danmakuEnabled={danmakuEnabled}
onToggleDanmaku={onToggleDanmaku}
+32
View File
@@ -0,0 +1,32 @@
export type SubtitleChineseConverter = (text: string) => string
function commaIndexAtField(line: string, fieldCount: number): number {
let index = -1
for (let field = 0; field < fieldCount; field += 1) {
index = line.indexOf(',', index + 1)
if (index < 0) return -1
}
return index
}
/**
* Convert only the Text column of ASS/SSA Dialogue lines. ASS styling tags,
* drawings, timing and positioning metadata are bytes-for-bytes preserved so
* the event can still be rendered by libass after a Chinese conversion.
*/
export function convertASSContent(
content: string,
convert: SubtitleChineseConverter,
): string {
return content
.replace(/^\uFEFF/, '')
.split(/\r?\n/)
.map((line) => {
const lower = line.toLowerCase()
if (!lower.startsWith('dialogue:')) return line
const textIndex = commaIndexAtField(line, 9)
if (textIndex < 0) return line
return `${line.slice(0, textIndex + 1)}${convert(line.slice(textIndex + 1))}`
})
.join('\n')
}
+99
View File
@@ -0,0 +1,99 @@
import type { CSSProperties } from 'react'
export type SubtitlePosition = 'auto' | 'bottom' | 'lower' | 'middle' | 'top'
export type SubtitleStylePreset = 'shadow' | 'outline' | 'box'
export const SUBTITLE_POSITION_OPTIONS: Array<[SubtitlePosition, string]> = [
['auto', '默认位置'],
['bottom', '贴底'],
['lower', '下方'],
['middle', '居中'],
['top', '顶部'],
]
export const SUBTITLE_STYLE_OPTIONS: Array<[SubtitleStylePreset, string]> = [
['shadow', '柔和阴影'],
['outline', '黑边描边'],
['box', '半透明底衬'],
]
const POSITION_KEY = 'mebox.subtitle.position'
const STYLE_KEY = 'mebox.subtitle.style'
export function normalizeSubtitlePosition(value: unknown): SubtitlePosition {
return value === 'bottom' || value === 'lower' || value === 'middle' || value === 'top'
? value
: 'auto'
}
export function normalizeSubtitleStyle(value: unknown): SubtitleStylePreset {
return value === 'outline' || value === 'box' ? value : 'shadow'
}
export function loadSubtitlePosition(): SubtitlePosition {
if (typeof window === 'undefined') return 'auto'
try {
return normalizeSubtitlePosition(window.localStorage.getItem(POSITION_KEY))
} catch {
return 'auto'
}
}
export function loadSubtitleStyle(): SubtitleStylePreset {
if (typeof window === 'undefined') return 'shadow'
try {
return normalizeSubtitleStyle(window.localStorage.getItem(STYLE_KEY))
} catch {
return 'shadow'
}
}
export function saveSubtitlePosition(position: SubtitlePosition): void {
try {
window.localStorage.setItem(POSITION_KEY, position)
} catch {
// localStorage may be unavailable in private mode; the player still works.
}
}
export function saveSubtitleStyle(style: SubtitleStylePreset): void {
try {
window.localStorage.setItem(STYLE_KEY, style)
} catch {
// localStorage may be unavailable in private mode; the player still works.
}
}
export function subtitleTextStyle(style: SubtitleStylePreset): CSSProperties {
if (style === 'outline') {
return {
fontWeight: 700,
textShadow: [
'-1.5px 0 #000',
'1.5px 0 #000',
'0 -1.5px #000',
'0 1.5px #000',
'-1px -1px #000',
'1px -1px #000',
'-1px 1px #000',
'1px 1px #000',
].join(', '),
}
}
if (style === 'box') {
return {
fontWeight: 600,
background: 'rgba(0, 0, 0, 0.72)',
padding: '0.16em 0.5em',
borderRadius: '0.24em',
boxDecorationBreak: 'clone',
WebkitBoxDecorationBreak: 'clone',
textShadow: '0 1px 2px rgba(0, 0, 0, 0.85)',
}
}
return {
fontWeight: 500,
textShadow:
'0 1px 3px rgba(0, 0, 0, 0.95), 0 0 8px rgba(0, 0, 0, 0.85), 0 0 16px rgba(0, 0, 0, 0.65)',
}
}
+94
View File
@@ -0,0 +1,94 @@
export type SubtitleCueAlignment = 'start' | 'center' | 'end' | 'left' | 'right'
export type SubtitleCueVertical = 'rl' | 'lr'
export type SubtitleCuePositionAlignment = SubtitleCueAlignment | 'line-left' | 'line-right' | 'auto'
export type SubtitleCueSettings = {
align?: SubtitleCueAlignment
line?: string
lineAlign?: SubtitleCueAlignment
position?: number
positionAlign?: SubtitleCuePositionAlignment
size?: number
vertical?: SubtitleCueVertical
}
export type SubtitleCue = {
startTime: number
endTime: number
text: string
settings: SubtitleCueSettings
}
function parseVTTTimestamp(value: string): number {
const parts = value.trim().replace(',', '.').split(':')
if (parts.length !== 2 && parts.length !== 3) return Number.NaN
const seconds = Number(parts.pop())
const minutes = Number(parts.pop())
const hours = parts.length > 0 ? Number(parts.pop()) : 0
if (![hours, minutes, seconds].every(Number.isFinite)) return Number.NaN
return hours * 3600 + minutes * 60 + seconds
}
function parseCueSettings(tokens: string[]): SubtitleCueSettings {
const settings: SubtitleCueSettings = {}
for (const token of tokens) {
const colon = token.indexOf(':')
if (colon <= 0) continue
const key = token.slice(0, colon).toLowerCase()
const value = token.slice(colon + 1)
if (key === 'align') {
if (['start', 'center', 'end', 'left', 'right'].includes(value)) {
settings.align = value as SubtitleCueAlignment
}
} else if (key === 'line') {
settings.line = value
} else if (key === 'linealign') {
if (['start', 'center', 'end'].includes(value)) {
settings.lineAlign = value as SubtitleCueAlignment
}
} else if (key === 'position') {
const position = Number.parseFloat(value)
if (Number.isFinite(position)) settings.position = Math.min(100, Math.max(0, position))
} else if (key === 'positionalign') {
if (['start', 'center', 'end', 'line-left', 'line-right', 'auto'].includes(value)) {
settings.positionAlign = value as SubtitleCuePositionAlignment
}
} else if (key === 'size') {
const size = Number.parseFloat(value)
if (Number.isFinite(size)) settings.size = Math.min(100, Math.max(0, size))
} else if (key === 'vertical') {
if (value === 'rl' || value === 'lr') settings.vertical = value
}
}
return settings
}
export function parseWebVTTCues(body: string): SubtitleCue[] {
const blocks = body
.replace(/^\uFEFF/, '')
.replace(/\r\n?/g, '\n')
.split(/\n{2,}/)
const cues: SubtitleCue[] = []
for (const block of blocks) {
const lines = block.split('\n')
const timingIndex = lines.findIndex((line) => line.includes('-->'))
if (timingIndex < 0) continue
const [rawStart, rawRest] = lines[timingIndex].split('-->', 2)
const rest = rawRest.trim().split(/\s+/)
const startTime = parseVTTTimestamp(rawStart)
const endTime = parseVTTTimestamp(rest[0] || '')
const text = lines.slice(timingIndex + 1).join('\n').trim()
if (Number.isFinite(startTime) && Number.isFinite(endTime) && endTime >= startTime && text) {
cues.push({
startTime,
endTime,
text,
settings: parseCueSettings(rest.slice(1)),
})
}
}
return cues
}
+5
View File
@@ -21,6 +21,11 @@ export default defineConfig({
},
},
},
worker: {
// JASSUB ships its libass worker as an ES module. Vite's default IIFE
// worker output cannot split that worker's own imports.
format: 'es',
},
build: {
outDir: 'dist',
sourcemap: false,