From 11c7cd02866abf857d432d61aa67b38204fc81c4 Mon Sep 17 00:00:00 2001 From: InfinityPacer Date: Wed, 21 Oct 2026 10:12:16 +0800 Subject: [PATCH 1/4] fix: keep SRT cue text that follows a blank line The sidecar parser split files on blank lines and treated each block as a cue, so an SRT cue whose text contained an empty line lost everything after it. In SRT a block without a timing line now continues the previous cue, and a block after a cue with an invalid timing line is dropped with it. WebVTT keeps ending a cue at a blank line, so NOTE, STYLE and REGION blocks between cues are still skipped. Co-Authored-By: Claude Opus 5.5 --- .../src/sidecar-subtitles.test.ts | 84 +++++++++++++++++++ .../chrome-extension/src/sidecar-subtitles.ts | 57 ++++++++++--- 2 files changed, 130 insertions(+), 11 deletions(-) diff --git a/apps/chrome-extension/src/sidecar-subtitles.test.ts b/apps/chrome-extension/src/sidecar-subtitles.test.ts index fbf58d8..cf0e37b 100644 --- a/apps/chrome-extension/src/sidecar-subtitles.test.ts +++ b/apps/chrome-extension/src/sidecar-subtitles.test.ts @@ -57,6 +57,90 @@ describe('外挂字幕文件解析', () => { ]); }); + it('SRT 台词中的空行之后仍属于上一句,时间不合法的块连同续行一起跳过', () => { + const srt = [ + '1', + '00:00:01,000 --> 00:00:02,000', + '第一行', + '', + '空行之后', + '', + '2', + '00:00:03,000 --> 00:00:02,000', + '倒序', + '', + '倒序的续行', + '', + '3', + '00:00:04,000 --> 00:00:05,000', + '第三句', + ].join('\n'); + + expect(parseSidecarSubtitles(srt, 'srt')).toEqual([ + { startSeconds: 1, endSeconds: 2, text: '第一行\n空行之后' }, + { startSeconds: 4, endSeconds: 5, text: '第三句' }, + ]); + }); + + it('SRT 句与句之间隔着多个空行(含 CRLF 与空白行)时仍逐句解析,末尾孤立的序号不并入', () => { + const srt = [ + '', + '1', + '00:00:01,000 --> 00:00:02,000', + '第一句', + '', + '', + '2', + '00:00:03,000 --> 00:00:04,000', + '第二句', + ' ', + '', + '', + '3', + '00:00:05,000 --> 00:00:06,000', + '第三句', + '', + '续行', + '', + '', + '4', + '', + ].join('\r\n'); + + expect(parseSidecarSubtitles(srt, 'srt')).toEqual([ + { startSeconds: 1, endSeconds: 2, text: '第一句' }, + { startSeconds: 3, endSeconds: 4, text: '第二句' }, + { startSeconds: 5, endSeconds: 6, text: '第三句\n续行' }, + ]); + }); + + it('WebVTT cue 之后的 NOTE、STYLE、REGION 块不并入台词', () => { + const vtt = [ + 'WEBVTT', + '', + 'REGION', + 'id:bottom width:40%', + '', + '00:00:01.000 --> 00:00:02.000', + '第一句', + '', + 'NOTE 中途注释', + '', + 'STYLE', + '::cue { color: red }', + '', + '00:00:03.000 --> 00:00:04.000', + '第二句', + '', + 'NOTE 结尾注释', + ].join('\n'); + + expect(parseSidecarSubtitles(vtt, 'webvtt')).toEqual([ + { startSeconds: 1, endSeconds: 2, text: '第一句' }, + { startSeconds: 3, endSeconds: 4, text: '第二句' }, + ]); + }); + it('不是字幕的文本得到空列表', () => { expect(parseSidecarSubtitles('not subtitles', 'srt')).toEqual([]); expect(parseSidecarSubtitles('', 'webvtt')).toEqual([]); diff --git a/apps/chrome-extension/src/sidecar-subtitles.ts b/apps/chrome-extension/src/sidecar-subtitles.ts index 961e77e..635c2c6 100644 --- a/apps/chrome-extension/src/sidecar-subtitles.ts +++ b/apps/chrome-extension/src/sidecar-subtitles.ts @@ -10,29 +10,64 @@ const SRT_TIMING = /** * 把外挂字幕文件解析为按开始时间排序的 cue。时间是原媒体时间;文本与内嵌字幕一样归一为 * WebVTT cue 文本。时间行不合法的块整块跳过,不影响其余字幕。 + * + * SRT 台词里可能出现空行,空行之后、下一个时间行之前的文字仍属于上一句。WebVTT 的空行 + * 结束 cue,之后没有时间行的块是 NOTE、STYLE、REGION 或文件头,不并入台词。 */ export const parseSidecarSubtitles = (text: string, format: TextSubtitleFormat): SubtitleCue[] => { const source = text.replace(/^\uFEFF/, '').replace(/\r\n?/g, '\n'); const cues: SubtitleCue[] = []; - for (const block of source.split(/\n[ \t]*\n/)) { + let pending: PendingCue | undefined; + const finish = (): void => { + if (!pending) return; + const normalized = normalizeSubtitleText(pending.lines.join('\n'), format); + if (normalized) cues.push({ ...pending.timing, ...normalized }); + pending = undefined; + }; + const blocks = source.trimEnd().split(/\n(?:[ \t]*\n)+/); + for (const [blockIndex, block] of blocks.entries()) { const lines = block.split('\n'); + while (lines.length > 0 && lines[0]?.trim() === '') lines.shift(); const timingIndex = lines.findIndex((line) => line.includes('-->')); - // WebVTT 的 NOTE、STYLE、REGION 块与文件头没有时间行;SRT 的时间行前最多一行序号。 - if (timingIndex < 0 || timingIndex > 1) continue; - const timing = SRT_TIMING.exec(lines[timingIndex] ?? ''); - if (!timing) continue; - const startSeconds = timestamp(timing[1], timing[2], timing[3], timing[4]); - const endSeconds = timestamp(timing[5], timing[6], timing[7], timing[8]); - if (startSeconds === undefined || endSeconds === undefined || endSeconds <= startSeconds) { + // SRT 的时间行前最多一行序号;没有时间行的块在 SRT 里是上一句的续行, + // 文件末尾只剩一个序号的块是没写完的下一句,不并入。 + if (timingIndex < 0 || timingIndex > 1) { + const danglingIndex = + blockIndex === blocks.length - 1 && + lines.length === 1 && + /^\s*\d+\s*$/.test(lines[0] ?? ''); + if (format === 'srt' && pending && !danglingIndex) pending.lines.push(...lines); continue; } - const normalized = normalizeSubtitleText(lines.slice(timingIndex + 1).join('\n'), format); - if (!normalized) continue; - cues.push({ startSeconds, endSeconds, ...normalized }); + finish(); + const timing = parseTiming(lines[timingIndex] ?? ''); + if (timing) pending = { timing, lines: lines.slice(timingIndex + 1) }; } + finish(); return cues.sort((left, right) => left.startSeconds - right.startSeconds); }; +interface CueTiming { + readonly startSeconds: number; + readonly endSeconds: number; +} + +interface PendingCue { + readonly timing: CueTiming; + readonly lines: string[]; +} + +const parseTiming = (line: string): CueTiming | undefined => { + const timing = SRT_TIMING.exec(line); + if (!timing) return undefined; + const startSeconds = timestamp(timing[1], timing[2], timing[3], timing[4]); + const endSeconds = timestamp(timing[5], timing[6], timing[7], timing[8]); + if (startSeconds === undefined || endSeconds === undefined || endSeconds <= startSeconds) { + return undefined; + } + return { startSeconds, endSeconds }; +}; + const timestamp = ( hours: string | undefined, minutes: string | undefined, From b1315b834b5e0a953ba02a4f7b30dffe534f7c30 Mon Sep 17 00:00:00 2001 From: InfinityPacer Date: Wed, 21 Oct 2026 10:12:58 +0800 Subject: [PATCH 2/4] fix: notify a failure after ready once, through Plex when it can Since the hook started handing read-class takeover failures to Plex as a Shaka network error, a read failure after ready showed both the s1002 dialog and the in-video interruption notice. Decode and format failures after ready, which the hook hands to Plex as a media error, did the same. The host no longer shows the interruption notice on its own after ready. The hook knows whether Plex's player actually received an error event and replies with takeover-error-unsurfaced when it did not, because the failure code has no Plex error surface, the Shaka error factory is missing, or dispatching threw. Only then does the host show its notice, so playback never stalls silently. Nothing is reported when Plex has already started another load or for the media_detached stop code. Co-Authored-By: Claude Opus 5.5 --- .../src/plex-native-host.test.ts | 48 +++++++++++++++- apps/chrome-extension/src/plex-native-host.ts | 19 ++++++- docs/architecture.md | 16 ++++-- packages/adapter-plex/src/native-hook.test.ts | 56 +++++++++++++++++++ packages/adapter-plex/src/native-hook.ts | 49 +++++++++------- .../adapter-plex/src/native-protocol.test.ts | 18 ++++++ packages/adapter-plex/src/native-protocol.ts | 16 ++++++ 7 files changed, 190 insertions(+), 32 deletions(-) diff --git a/apps/chrome-extension/src/plex-native-host.test.ts b/apps/chrome-extension/src/plex-native-host.test.ts index da0ecd2..da9d284 100644 --- a/apps/chrome-extension/src/plex-native-host.test.ts +++ b/apps/chrome-extension/src/plex-native-host.test.ts @@ -683,10 +683,11 @@ describe('PlexNativePlaybackHost', () => { }); await host.dispose(); }); - it('接管就绪后播放中断时提示用户,并在 video 上记下结束原因', async () => { + it('接管就绪后的失败交给了 Plex 错误框时不叠加中断提示,并在 video 上记下结束原因', async () => { const media = new TestMediaElement(); const completion = deferred(); const interruptions: string[] = []; + const failures: string[] = []; const posted: PlexNativeMessage[] = []; const host = new PlexNativePlaybackHost({ createPlaybackRuntime: () => ({ @@ -700,6 +701,7 @@ describe('PlexNativePlaybackHost', () => { resolveMediaElement: () => media as unknown as HTMLMediaElement, postMessage: (message) => posted.push(message), readStartSeconds: () => undefined, + presentFailure: (failure) => failures.push(failure.code), presentInterruption: (failure) => interruptions.push(failure.code), createNoticeId: () => 'notice_id_1234567890', }); @@ -712,7 +714,8 @@ describe('PlexNativePlaybackHost', () => { completion.reject(new MediaWorkerRemoteError('stream_failed', 'RangeProtocolError')); await flush(); - expect(interruptions).toEqual(['media_read_failed']); + expect(failures).toEqual(['media_read_failed']); + expect(interruptions).toEqual([]); expect(media.dataset.shimweaveSessionEnd).toBe('media_read_failed'); expect(media.dataset.shimweaveSessionEndDetail).toBe( 'MediaWorkerRemoteError stream_failed RangeProtocolError', @@ -721,6 +724,47 @@ describe('PlexNativePlaybackHost', () => { await host.dispose(); }); + it('Hook 回报就绪后的失败没能交给 Plex 时提示一次中断', async () => { + const media = new TestMediaElement(); + const completion = deferred(); + const interruptions: string[] = []; + const host = new PlexNativePlaybackHost({ + createPlaybackRuntime: () => ({ + start: async () => + activePlayback( + vi.fn(async () => undefined), + completion.promise, + ), + stop: vi.fn(async () => undefined), + }), + resolveMediaElement: () => media as unknown as HTMLMediaElement, + postMessage: vi.fn(), + readStartSeconds: () => undefined, + presentInterruption: (failure) => interruptions.push(failure.code), + createNoticeId: () => 'notice_id_1234567890', + }); + claim(host, 'native_session_1234567890'); + await flush(); + + completion.reject(new MediaWorkerRemoteError('stream_failed', 'RangeProtocolError')); + await flush(); + const unsurfaced = (sessionId: string, code: string): PlexNativeMessage => ({ + protocol: PLEX_NATIVE_PROTOCOL, + sender: 'main-hook', + type: 'takeover-error-unsurfaced', + sessionId, + code, + }); + host.acceptPageMessage(unsurfaced('other_session_1234567890', 'media_read_failed')); + host.acceptPageMessage(unsurfaced('native_session_1234567890', 'playback_failed')); + expect(interruptions).toEqual([]); + + host.acceptPageMessage(unsurfaced('native_session_1234567890', 'media_read_failed')); + host.acceptPageMessage(unsurfaced('native_session_1234567890', 'media_read_failed')); + expect(interruptions).toEqual(['media_read_failed']); + await host.dispose(); + }); + it('Plex 结束播放先卸载 video 时静默收尾,不提示中断', async () => { const media = new TestMediaElement(); const completion = deferred(); diff --git a/apps/chrome-extension/src/plex-native-host.ts b/apps/chrome-extension/src/plex-native-host.ts index 00861ec..01a36ba 100644 --- a/apps/chrome-extension/src/plex-native-host.ts +++ b/apps/chrome-extension/src/plex-native-host.ts @@ -70,8 +70,8 @@ export interface PlexNativePlaybackHostOptions { language: string | undefined, ): SidecarRendererPort; /** - * 接管就绪后播放中断时提示。此时 Plex 的加载早已完成,不会弹出自己的错误框, - * 不提示的话画面只会停在缓冲状态。 + * 接管就绪后播放中断、且 Hook 报告没能交给 Plex 错误弹窗时提示。交给了 Plex 的失败 + * 由它的弹窗呈现,这里不再重复;没交给的话 Plex 自己不会报错,画面只会停在缓冲状态。 */ presentInterruption?(failure: PlaybackFailurePresentation): void; createNoticeId?: () => string; @@ -166,6 +166,10 @@ export class PlexNativePlaybackHost { private active: NativePlaybackSession | undefined; private generation = 0; private disposed = false; + /** 就绪后失败的会话,等 Hook 回报是否交给了 Plex 再决定要不要提示中断。 */ + private interruption: + | { readonly sessionId: string; readonly failure: PlaybackFailurePresentation } + | undefined; constructor(options: PlexNativePlaybackHostOptions) { this.options = options; @@ -259,6 +263,14 @@ export class PlexNativePlaybackHost { if (source?.noticeId === message.noticeId) this.releaseSource(message.requestKey, source); return; } + if (message.type === 'takeover-error-unsurfaced') { + const interruption = this.interruption; + if (interruption?.sessionId !== message.sessionId) return; + if (interruption.failure.code !== message.code) return; + this.interruption = undefined; + this.options.presentInterruption?.(interruption.failure); + return; + } if (message.type === 'takeover-stop') { void this.stopSession(message.sessionId).finally(() => { this.options.postMessage({ @@ -274,6 +286,7 @@ export class PlexNativePlaybackHost { async dispose(): Promise { if (this.disposed) return; this.disposed = true; + this.interruption = undefined; this.generation += 1; for (const [requestKey, source] of this.sources) this.releaseSource(requestKey, source); this.sources.clear(); @@ -509,7 +522,7 @@ export class PlexNativePlaybackHost { const interrupted = session.playback !== undefined; await this.stopActive(failure.code, error); this.options.presentFailure?.(failure, formats); - if (interrupted) this.options.presentInterruption?.(failure); + this.interruption = interrupted ? { sessionId: session.sessionId, failure } : undefined; this.postTakeoverError(session.sessionId, failure.code); } diff --git a/docs/architecture.md b/docs/architecture.md index aaa75b4..a6c6062 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -386,12 +386,16 @@ MSE 和状态回写句柄,再通过当前 Shaka Player 的原生错误事件 网络、取消或配额错误写成永久格式结论。 ready 之后的其他失败(读取、Worker、缓冲等)不会自然进入 Plex 错误弹窗。接管已经卸载 Shaka 的 -媒体数据面,Plex 只会停在缓冲状态,video 也没有可用媒体,因此扩展在画面上方显示一次中断提示, -沿用失败说明并附上稳定错误码。读取类失败(`media_read_failed`、`source_expired`)无论发生在 -ready 之前还是之后,Hook 都在会话释放后通过当前 Shaka Player 派发一次致命网络错误 -(`HTTP_ERROR`),让 Plex 显示自己的 s1002 错误弹窗。这类失败不写入同媒体阻断, -用户重试时仍会重新接管。释放期间 Plex 已发起新的 load(例如切集或用户关闭)时不再派发, -`media_detached` 等停止原因也不派发。原 Plex 链路本身无法播放这些 STRM 源,所以不在失败后 +媒体数据面,Plex 只会停在缓冲状态,video 也没有可用媒体。读取类失败(`media_read_failed`、 +`source_expired`)无论发生在 ready 之前还是之后,Hook 都在会话释放后通过当前 Shaka Player +派发一次致命网络错误(`HTTP_ERROR`),让 Plex 显示自己的 s1002 错误弹窗。这类失败不写入同媒体 +阻断,用户重试时仍会重新接管。释放期间 Plex 已发起新的 load(例如切集或用户关闭)时不再派发, +`media_detached` 等停止原因也不派发。 + +每次失败只提示一次。ready 之后的失败由 Hook 判断 Plex 的播放器是否真的收到了错误事件,没收到时 +(失败类别没有对应的 Plex 错误表面、当前 Shaka 构造不出错误事件或派发抛错)回报 +`takeover-error-unsurfaced`,宿主这时才在画面上方显示中断提示,沿用失败说明并附上稳定错误码。 +交给了 Plex 的失败只由它的弹窗呈现,Plex 已发起新 load 时两边都不提示。原 Plex 链路本身无法播放这些 STRM 源,所以不在失败后 重新调用 Plex 的原始 load。每次会话结束时,video 的 `data-shimweave-session-end` 记下结束原因(失败为错误码,Hook 或新接管停止为 `stopped`, 播完为 `ended`),失败时 `data-shimweave-session-end-detail` 另记错误名与 Worker 已脱敏的 diff --git a/packages/adapter-plex/src/native-hook.test.ts b/packages/adapter-plex/src/native-hook.test.ts index e42e2cd..fee6909 100644 --- a/packages/adapter-plex/src/native-hook.test.ts +++ b/packages/adapter-plex/src/native-hook.test.ts @@ -233,6 +233,7 @@ describe('PlexNativeHookController', () => { await flush(); expect(fixture.dispatchEvent).toHaveBeenCalledWith({ type: 'error', mediaErrorCode: 3 }); + expect(posted.some((message) => message.type === 'takeover-error-unsurfaced')).toBe(false); const retryUrl = startUrl.replace('playback-1', 'playback-2'); await expect(fixture.player.load(retryUrl)).rejects.toBeInstanceOf(Error); expect(fixture.originalLoad).toHaveBeenCalledOnce(); @@ -284,6 +285,13 @@ describe('PlexNativeHookController', () => { await flush(); expect(fixture.dispatchEvent).not.toHaveBeenCalled(); + expect(posted).toContainEqual({ + protocol: PLEX_NATIVE_PROTOCOL, + sender: 'main-hook', + type: 'takeover-error-unsurfaced', + sessionId: takeover.sessionId, + code: 'media_worker_remote_error', + }); const retryUrl = startUrl.replace('playback-1', 'playback-2'); const retry = fixture.player.load(retryUrl); await flush(); @@ -319,6 +327,7 @@ describe('PlexNativeHookController', () => { await flush(); expect(fixture.dispatchEvent).toHaveBeenCalledExactlyOnceWith({ type: 'error', network: true }); + expect(posted.some((message) => message.type === 'takeover-error-unsurfaced')).toBe(false); const retry = fixture.player.load(startUrl.replace('playback-1', 'playback-2')); await flush(); hook.accept({ @@ -393,9 +402,56 @@ describe('PlexNativeHookController', () => { await flush(); expect(fixture.dispatchEvent).not.toHaveBeenCalled(); + expect(posted.some((message) => message.type === 'takeover-error-unsurfaced')).toBe(false); hook.dispose(); }); + it('就绪后的错误没能交给 Plex 时请宿主自己提示', async () => { + for (const [code, failing] of [ + ['media_read_failed', 'missing'], + ['media_decode_error', 'missing'], + ['source_expired', 'throws'], + ] as const) { + const fixture = createRuntime(async () => Promise.reject(new Error('manifest rejected'))); + if (failing === 'throws') { + fixture.dispatchEvent.mockImplementation(() => { + throw new Error('page changed'); + }); + } + const runtime: PlexShakaRuntime = + failing === 'missing' + ? { Player: fixture.runtime.Player, version: fixture.runtime.version } + : fixture.runtime; + const posted: PlexNativeMessage[] = []; + const hook = new PlexNativeHookController({ + postMessage: (message) => posted.push(message), + rejectionGraceMs: 0, + }); + hook.install(runtime); + const loading = fixture.player.load(startUrl); + await flush(); + hook.accept(sourceAvailable()); + await flush(); + const takeover = posted.find((message) => message.type === 'takeover-start'); + if (takeover?.type !== 'takeover-start') throw new Error('takeover was not started'); + hook.accept(hostMessage('takeover-ready', takeover.sessionId)); + await loading; + + hook.accept(takeoverError(takeover.sessionId, code)); + hook.accept(hostMessage('takeover-stopped', takeover.sessionId)); + await flush(); + + expect(posted.at(-1)).toEqual({ + protocol: PLEX_NATIVE_PROTOCOL, + sender: 'main-hook', + type: 'takeover-error-unsurfaced', + sessionId: takeover.sessionId, + code, + }); + hook.dispose(); + } + }); + it('同一 Part 的 Plex 重试复用已经建立的接管会话', async () => { const fixture = createRuntime(async () => Promise.reject(new Error('manifest rejected'))); const posted: PlexNativeMessage[] = []; diff --git a/packages/adapter-plex/src/native-hook.ts b/packages/adapter-plex/src/native-hook.ts index e09aa8e..93a428b 100644 --- a/packages/adapter-plex/src/native-hook.ts +++ b/packages/adapter-plex/src/native-hook.ts @@ -373,18 +373,32 @@ export class PlexNativeHookController { if (mediaErrorCode !== undefined) { this.blockedSource = { sourceKey: tracked.sourceKey, mediaErrorCode }; } + const takeover = tracked.takeover; const loadRequests = this.loadRequests; await this.stopTakeover(tracked, new PlexNativeTakeoverError(code)); - if (mediaErrorCode === undefined) { + // 收尾期间 Plex 已发起新 load 时它已经离开这次播放,不再报告旧错误。 + if (this.loadRequests !== loadRequests) return; + let surfaced = false; + if (mediaErrorCode !== undefined) { + if (this.blockedSource?.sourceKey !== tracked.sourceKey) return; + surfaced = this.dispatchErrorEvent( + tracked.player, + this.createMediaErrorEvent?.(mediaErrorCode), + ); + } else if (PLEX_NETWORK_FAILURE_CODES.has(code)) { // 读取类失败不阻断重试。接管已卸载 Shaka 的媒体数据面,Plex 自己不会再报错, // 不交给它的错误表面就只剩空白画面和无限缓冲。 - if (PLEX_NETWORK_FAILURE_CODES.has(code) && this.loadRequests === loadRequests) { - this.dispatchErrorEvent(tracked.player, this.createNetworkErrorEvent?.()); - } - return; + surfaced = this.dispatchErrorEvent(tracked.player, this.createNetworkErrorEvent?.()); } - if (this.blockedSource?.sourceKey !== tracked.sourceKey) return; - this.dispatchMediaError(tracked.player, mediaErrorCode); + // 就绪前的失败随 load 的拒绝交给 Plex;就绪后 Plex 没有收到错误事件时由宿主自己提示。 + if (!takeover?.ready || surfaced || code === 'media_detached') return; + this.postMessage({ + protocol: PLEX_NATIVE_PROTOCOL, + sender: 'main-hook', + type: 'takeover-error-unsurfaced', + sessionId: takeover.sessionId, + code, + }); } private rejectBlockedLoad( @@ -397,29 +411,22 @@ export class PlexNativeHookController { type: 'blocked-source-retry', sourceKey: blocked.sourceKey, }); - const event = this.dispatchMediaError(player, blocked.mediaErrorCode); + const event = this.createMediaErrorEvent?.(blocked.mediaErrorCode); + this.dispatchErrorEvent(player, event); const code = blocked.mediaErrorCode === 3 ? 'media_decode_error' : 'media_format_error'; return Promise.reject(event?.detail ?? new PlexNativeTakeoverError(code)); } - private dispatchMediaError( - player: ShakaPlayerLike, - mediaErrorCode: 3 | 4, - ): ShakaEventLike | undefined { - return this.dispatchErrorEvent(player, this.createMediaErrorEvent?.(mediaErrorCode)); - } - - private dispatchErrorEvent( - player: ShakaPlayerLike, - event: ShakaEventLike | undefined, - ): ShakaEventLike | undefined { - if (!event) return undefined; + /** 返回 Plex 的播放器是否收到了错误事件。 */ + private dispatchErrorEvent(player: ShakaPlayerLike, event: ShakaEventLike | undefined): boolean { + if (!event) return false; try { player.dispatchEvent(event); + return true; } catch { // Shaka 错误桥属于失败收口路径,页面实现变化不得反向破坏会话释放。 + return false; } - return event; } private async stopActive(): Promise { diff --git a/packages/adapter-plex/src/native-protocol.test.ts b/packages/adapter-plex/src/native-protocol.test.ts index d3f60e4..070a03d 100644 --- a/packages/adapter-plex/src/native-protocol.test.ts +++ b/packages/adapter-plex/src/native-protocol.test.ts @@ -28,6 +28,24 @@ describe('Plex native bridge protocol', () => { sourceKey: 'plex:source', }), ).toBe(true); + expect( + isPlexNativeMessage({ + protocol: PLEX_NATIVE_PROTOCOL, + sender: 'main-hook', + type: 'takeover-error-unsurfaced', + sessionId: 'native_session_1234567890', + code: 'media_read_failed', + }), + ).toBe(true); + expect( + isPlexNativeMessage({ + protocol: PLEX_NATIVE_PROTOCOL, + sender: 'extension-host', + type: 'takeover-error-unsurfaced', + sessionId: 'native_session_1234567890', + code: 'media_read_failed', + }), + ).toBe(false); expect( isPlexNativeMessage({ protocol: PLEX_NATIVE_PROTOCOL, diff --git a/packages/adapter-plex/src/native-protocol.ts b/packages/adapter-plex/src/native-protocol.ts index 2f9426b..3a6cf85 100644 --- a/packages/adapter-plex/src/native-protocol.ts +++ b/packages/adapter-plex/src/native-protocol.ts @@ -59,6 +59,14 @@ export type PlexNativeMessage = sessionId: string; code: string; } + | { + /** 就绪后的接管失败没能交给 Plex 的错误表面,由宿主自己提示中断。 */ + protocol: typeof PLEX_NATIVE_PROTOCOL; + sender: 'main-hook'; + type: 'takeover-error-unsurfaced'; + sessionId: string; + code: string; + } | { protocol: typeof PLEX_NATIVE_PROTOCOL; sender: 'main-hook'; @@ -137,6 +145,14 @@ export const isPlexNativeMessage = (value: unknown): value is PlexNativeMessage exactKeys(value, [...BASE_KEYS, 'sessionId', 'code']) ); } + if (value.type === 'takeover-error-unsurfaced') { + return ( + value.sender === 'main-hook' && + opaqueId(value.sessionId) && + nonEmptyString(value.code) && + exactKeys(value, [...BASE_KEYS, 'sessionId', 'code']) + ); + } if (value.type === 'takeover-stop') { return ( value.sender === 'main-hook' && From 86619ec16f052826b0644b4922b6103164164f59 Mon Sep 17 00:00:00 2001 From: InfinityPacer Date: Wed, 21 Oct 2026 10:14:37 +0800 Subject: [PATCH 3/4] fix: ask Plex for UTF-8 sidecar subtitles and reject undecodable ones Sidecar subtitles were requested without an encoding and decoded as UTF-8, which relied on a reverse proxy in front of Plex converting them. GBK or Big5 files it could not identify, or any non-UTF-8 file served by Plex directly, showed as mojibake. The request now carries encoding=utf-8. Plex Media Server then detects the file encoding itself and converts it to UTF-8, and a proxy that forwards the query keeps working because it receives valid UTF-8. When Plex cannot convert either, the file is reported as an unsupported encoding instead of drawn as mojibake. The text counts as undecodable when it holds at least 4 replacement characters and they make up at least half of its non-ASCII characters. GBK and Big5 decoded as UTF-8 turn over 90% of their non-ASCII characters into replacement characters, so a mostly English file with a few garbled CJK words is still caught, while a stray bad byte in a small or a mostly valid UTF-8 file is not. The architecture document describes the new request and fallback. Co-Authored-By: Claude Opus 5.5 --- apps/chrome-extension/src/plex-content.ts | 8 +++- .../src/plex-native-host.test.ts | 12 ++++-- apps/chrome-extension/src/plex-native-host.ts | 18 +++++++-- .../src/plex-stream-selection.test.ts | 37 ++++++++++++++++++- .../src/plex-stream-selection.ts | 32 ++++++++++++++-- docs/architecture.md | 13 +++++-- .../adapter-plex/src/stream-selection.test.ts | 2 +- packages/adapter-plex/src/stream-selection.ts | 7 +++- 8 files changed, 108 insertions(+), 21 deletions(-) diff --git a/apps/chrome-extension/src/plex-content.ts b/apps/chrome-extension/src/plex-content.ts index 7daaf3f..2a34d9b 100644 --- a/apps/chrome-extension/src/plex-content.ts +++ b/apps/chrome-extension/src/plex-content.ts @@ -15,7 +15,7 @@ import { BrowserPlaybackRuntime } from './playback-runtime.js'; import { formatPlaybackInterrupted, formatSubtitleUnavailable } from './player-presentation.js'; import { PlexAudioNotice } from './plex-audio-notice.js'; import { PlexErrorPresenter } from './plex-error-presenter.js'; -import { PlexNativePlaybackHost } from './plex-native-host.js'; +import { PlexNativePlaybackHost, type SubtitleFileRead } from './plex-native-host.js'; import { isPlexSubtitleChangedMessage, PLEX_STREAM_SELECTION_MESSAGE, @@ -207,7 +207,7 @@ function requestStreamSelection(notice: PlexMediaSourceNotice): Promise { +): Promise { if (!notice.playbackReportId) return Promise.resolve(undefined); return new Promise((resolve) => { chrome.runtime.sendMessage( @@ -217,6 +217,10 @@ function requestSubtitleFile( resolve(undefined); return; } + if (response?.unreadable === true) { + resolve({ unavailable: 'unsupported_encoding' }); + return; + } resolve(typeof response?.text === 'string' ? response.text : undefined); }, ); diff --git a/apps/chrome-extension/src/plex-native-host.test.ts b/apps/chrome-extension/src/plex-native-host.test.ts index da9d284..edeee27 100644 --- a/apps/chrome-extension/src/plex-native-host.test.ts +++ b/apps/chrome-extension/src/plex-native-host.test.ts @@ -15,7 +15,7 @@ import { BrowserPlaybackProbeRequiredError, BrowserPlaybackUnsupportedError, } from './playback-runtime.js'; -import { PlexNativePlaybackHost } from './plex-native-host.js'; +import { PlexNativePlaybackHost, type SubtitleFileRead } from './plex-native-host.js'; const descriptor: MediaDescriptor = { sourceId: 'source-a', @@ -1006,7 +1006,10 @@ describe('PlexNativePlaybackHost 外挂字幕', () => { await options.holdStart; return playback; }); - const readSubtitleFile = vi.fn(async (_notice: PlexMediaSourceNotice, _key: string) => srt); + const readSubtitleFile = vi.fn( + async (_notice: PlexMediaSourceNotice, _key: string): Promise => + srt, + ); const resolveStreamSelection = vi.fn(async () => selection); const presentSubtitleUnavailable = vi.fn(); const host = new PlexNativePlaybackHost({ @@ -1120,13 +1123,16 @@ describe('PlexNativePlaybackHost 外挂字幕', () => { ); expect(context.readSubtitleFile).not.toHaveBeenCalled(); - context.readSubtitleFile.mockResolvedValueOnce(undefined as unknown as string); + context.readSubtitleFile.mockResolvedValueOnce(undefined); await context.change({ subtitle: external(20) }); context.readSubtitleFile.mockResolvedValueOnce('not subtitles'); await context.change({ subtitle: external(21) }); + context.readSubtitleFile.mockResolvedValueOnce({ unavailable: 'unsupported_encoding' }); + await context.change({ subtitle: external(22) }); expect(context.presentSubtitleUnavailable.mock.calls.slice(1)).toEqual([ ['read_failed'], ['read_failed'], + ['unsupported_encoding'], ]); expect(context.renderers).toHaveLength(0); await context.host.dispose(); diff --git a/apps/chrome-extension/src/plex-native-host.ts b/apps/chrome-extension/src/plex-native-host.ts index 01a36ba..7764e79 100644 --- a/apps/chrome-extension/src/plex-native-host.ts +++ b/apps/chrome-extension/src/plex-native-host.ts @@ -62,9 +62,12 @@ export interface PlexNativePlaybackHostOptions { presentSubtitleUnavailable?(reason: SubtitleUnavailableReason, codec?: string): void; /** * 读取外挂字幕文件(`/library/streams/{id}`)的文本,由后台带会话凭据读取; - * 失败时返回 undefined,页面提示字幕不可用。 + * 失败时返回 undefined,读到了却无法解码时返回不可用原因,页面都提示字幕不可用。 */ - readSubtitleFile?(notice: PlexMediaSourceNotice, key: string): Promise; + readSubtitleFile?( + notice: PlexMediaSourceNotice, + key: string, + ): Promise; createSidecarRenderer?( mediaElement: HTMLMediaElement, language: string | undefined, @@ -80,6 +83,9 @@ export interface PlexNativePlaybackHostOptions { sourceTtlMs?: number; } +/** 外挂字幕文件的读取结果:文本,或读到了文件但无法解码的原因。 */ +export type SubtitleFileRead = string | { readonly unavailable: 'unsupported_encoding' }; + interface NativePlaybackSession { readonly generation: number; readonly sessionId: string; @@ -479,11 +485,15 @@ export class PlexNativePlaybackHost { this.options.presentSubtitleUnavailable?.(resolved.reason, resolved.codec); return; } - const text = await ( + const read = await ( this.options.readSubtitleFile?.(session.notice, resolved.key) ?? Promise.resolve(undefined) ).catch(() => undefined); if (!current()) return; - const cues = text === undefined ? [] : parseSidecarSubtitles(text, resolved.format); + if (typeof read === 'object') { + this.options.presentSubtitleUnavailable?.(read.unavailable); + return; + } + const cues = read === undefined ? [] : parseSidecarSubtitles(read, resolved.format); if (cues.length === 0) { this.options.presentSubtitleUnavailable?.('read_failed'); return; diff --git a/apps/chrome-extension/src/plex-stream-selection.test.ts b/apps/chrome-extension/src/plex-stream-selection.test.ts index 311247d..ab1ab87 100644 --- a/apps/chrome-extension/src/plex-stream-selection.test.ts +++ b/apps/chrome-extension/src/plex-stream-selection.test.ts @@ -96,7 +96,7 @@ describe('Plex 外挂字幕文件', () => { expect(isPlexSubtitleFileMessage({ ...valid, reportId: 'short' })).toBe(false); }); - it('用请求头携带 Token 读取,按 UTF-8 解码', async () => { + it('要求 Plex 转成 UTF-8,用请求头携带 Token 读取,按 UTF-8 解码', async () => { const srt = '1\n00:00:01,000 --> 00:00:02,000\n你好\n'; const fetcher = vi.fn(async () => new Response(new TextEncoder().encode(srt), { status: 200 })); @@ -104,11 +104,44 @@ describe('Plex 外挂字幕文件', () => { await fetchPlexSubtitleFile(context, '/library/streams/20', fetcher as typeof fetch), ).toEqual({ text: srt }); const [url, init] = fetcher.mock.calls[0] as unknown as [string, RequestInit]; - expect(url).toBe('https://plex.example.test:32400/library/streams/20'); + expect(url).toBe('https://plex.example.test:32400/library/streams/20?encoding=utf-8'); expect(init.headers).toMatchObject({ 'X-Plex-Token': 'secret-token' }); expect(init.credentials).toBe('omit'); }); + it('Plex 没能转换的 GBK、Big5 字幕按编码不受支持返回,零星坏字节的 UTF-8 照常解码', async () => { + const read = async (bytes: readonly number[]) => + fetchPlexSubtitleFile( + context, + '/library/streams/20', + vi.fn(async () => new Response(Uint8Array.from(bytes), { status: 200 })), + ); + const ascii = (text: string) => Array.from(new TextEncoder().encode(text)); + const cue = (index: number, text: string) => + ascii(`${index}\n00:00:01,000 --> 00:00:02,000\n${text}\n\n`); + + // 「你好世界」的 GBK 与 Big5 字节。 + const gbk = [...cue(1, ''), 0xc4, 0xe3, 0xba, 0xc3, 0xca, 0xc0, 0xbd, 0xe7, 0x0a]; + const big5 = [...cue(1, ''), 0xa7, 0x41, 0xa6, 0x6e, 0xa5, 0x40, 0xac, 0xc9, 0x0a]; + expect(await read(gbk)).toEqual({ unreadable: true }); + expect(await read(big5)).toEqual({ unreadable: true }); + + // 两百句英文里只有一句是 GBK 的「中文」:替换字符不到全文的 0.1%,仍按编码不受支持返回。 + const english = Array.from({ length: 200 }, (_, i) => cue(i + 1, `line ${i + 1}`)).flat(); + expect(await read([...english, ...cue(201, ''), 0xd6, 0xd0, 0xce, 0xc4, 0x0a])).toEqual({ + unreadable: true, + }); + + // 只有一句的小文件里一个坏字节,替换字符占全文约 3%,照常显示。 + const tiny = await read([...cue(1, 'hi'), 0xff, 0x0a]); + expect(tiny.text).toContain('hi'); + // 中文 UTF-8 字幕里夹着几个坏字节,替换字符只占非 ASCII 字符的一小部分,照常显示。 + const chinese = Array.from({ length: 9 }, (_, i) => cue(i + 1, `第 ${i + 1} 句你好`)).flat(); + const stray = await read([...chinese, 0xff, 0xfe, 0xff, 0xfe, 0xff, 0x0a]); + expect(stray.text).toContain('第 9 句你好'); + expect(stray.unreadable).toBeUndefined(); + }); + it('失败、地址不对或超出大小上限时返回空应答', async () => { const failed = vi.fn(async () => new Response('', { status: 404 })); expect(await fetchPlexSubtitleFile(context, '/library/streams/20', failed)).toEqual({}); diff --git a/apps/chrome-extension/src/plex-stream-selection.ts b/apps/chrome-extension/src/plex-stream-selection.ts index e684cb6..81ba786 100644 --- a/apps/chrome-extension/src/plex-stream-selection.ts +++ b/apps/chrome-extension/src/plex-stream-selection.ts @@ -73,8 +73,10 @@ export interface PlexSubtitleFileMessage { } export interface PlexSubtitleFileResponse { - /** 读取失败、超时或超出大小上限时缺失。 */ + /** 读取失败、超时、超出大小上限或文本无法按 UTF-8 读出时缺失。 */ text?: string; + /** 读到了文件,但内容不是 UTF-8,Plex 也没能转换,页面提示编码不受支持。 */ + unreadable?: true; } export const isPlexSubtitleFileMessage = (value: unknown): value is PlexSubtitleFileMessage => { @@ -100,8 +102,18 @@ export const isPlexSubtitleChangedMessage = ( export const PLEX_SUBTITLE_FILE_MAX_BYTES = 8 * 1024 * 1024; /** - * 读取外挂字幕文件的文本。Cirvel 已把字幕统一转为 UTF-8,这里按 UTF-8 解码; - * 失败时返回空应答,页面提示字幕不可用,视频照常播放。 + * 判定文本不是 UTF-8 的替换字符门槛:至少这么多个,且占全部非 ASCII 字符的这一比例以上。 + * GBK、Big5 按 UTF-8 解码时九成以上的非 ASCII 字符是替换字符,即使字幕大多是英文、只夹几个 + * 汉字也能认出;合法 UTF-8 里零星的坏字节只占非 ASCII 字符的一小部分,小文件里一两个坏字节 + * 也达不到个数门槛,照常显示。 + */ +const MIN_REPLACEMENTS = 4; +const MIN_REPLACEMENT_SHARE = 0.5; + +/** + * 读取外挂字幕文件的文本。请求要求 Plex 转成 UTF-8(见 createPlexStreamFileRequest), + * 这里按 UTF-8 解码;Plex 认不出编码时原样给出字节,解码结果大量是替换字符,按编码 + * 不受支持返回,不显示乱码。读取失败时返回空应答,页面提示字幕不可用,视频照常播放。 */ export const fetchPlexSubtitleFile = async ( context: PlexTimelineContext, @@ -126,7 +138,8 @@ export const fetchPlexSubtitleFile = async ( if (Number.isFinite(declared) && declared > PLEX_SUBTITLE_FILE_MAX_BYTES) return {}; const bytes = await response.arrayBuffer(); if (bytes.byteLength > PLEX_SUBTITLE_FILE_MAX_BYTES) return {}; - return { text: new TextDecoder('utf-8').decode(bytes) }; + const text = new TextDecoder('utf-8').decode(bytes); + return isMostlyReplacement(text) ? { unreadable: true } : { text }; } catch { return {}; } finally { @@ -134,5 +147,16 @@ export const fetchPlexSubtitleFile = async ( } }; +const isMostlyReplacement = (text: string): boolean => { + let replacements = 0; + let nonAscii = 0; + for (let index = 0; index < text.length; index += 1) { + const code = text.charCodeAt(index); + if (code > 0x7f) nonAscii += 1; + if (code === 0xfffd) replacements += 1; + } + return replacements >= MIN_REPLACEMENTS && replacements >= nonAscii * MIN_REPLACEMENT_SHARE; +}; + const nonNegativeInteger = (value: unknown): value is number => typeof value === 'number' && Number.isSafeInteger(value) && value >= 0; diff --git a/docs/architecture.md b/docs/architecture.md index a6c6062..de715a8 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -305,10 +305,15 @@ Plex 字幕选择与音轨共用同一次元数据读取,取 Part 中标为选 也不静默显示错误字幕。 外挂字幕(没有流索引,带 `/library/streams/{id}`)不在媒体文件里,媒体运行时按不选字幕播放, -宿主在起播后经后台以播放会话凭据读取字幕文件(只接受这一形状的地址,上限 8 MiB,按 UTF-8 -解码,Cirvel 已把编码统一为 UTF-8),解析为与内嵌字幕相同的 cue,在另一条名为 -`ShimWeave sidecar` 的 TextTrack 上显示。只绘制 `srt` 与 `vtt`,ASS/SSA 等格式不读取文件,直接 -提示不可用;读不到文件或文件里没有字幕同样提示不可用。 +宿主在起播后经后台以播放会话凭据读取字幕文件(只接受这一形状的地址,上限 8 MiB),解析为与 +内嵌字幕相同的 cue,在另一条名为 `ShimWeave sidecar` 的 TextTrack 上显示。只绘制 `srt` 与 +`vtt`,ASS/SSA 等格式不读取文件,直接提示不可用;读不到文件或文件里没有字幕同样提示不可用。 + +读取请求带 `encoding=utf-8`,Plex Media Server 据此自己探测文件编码并转成 UTF-8,GBK、Big5 等 +编码的字幕不依赖前面是否有反代转码,经过转发查询参数的反代时收到的也已是 UTF-8。扩展按 UTF-8 +解码。Plex 认不出编码时原样给出字节,解码结果里替换字符至少 4 个且占非 ASCII 字符的一半以上 +(GBK、Big5 按 UTF-8 解码时九成以上是替换字符)时,提示字幕使用了暂不支持的编码,不显示乱码。 +合法 UTF-8 里零星的坏字节达不到这个门槛,照常显示。 Plex Web 在播放器里换字幕时先以 `PUT /library/parts/{id}?subtitleStreamID=` 记下选择,再重新 决策并重新起播会话,扩展随之开始新的接管,按新的选择读取内嵌字幕或外挂字幕,从原位置继续, diff --git a/packages/adapter-plex/src/stream-selection.test.ts b/packages/adapter-plex/src/stream-selection.test.ts index 20c3989..594e8a7 100644 --- a/packages/adapter-plex/src/stream-selection.test.ts +++ b/packages/adapter-plex/src/stream-selection.test.ts @@ -205,7 +205,7 @@ describe('Plex 字幕选择', () => { }; const request = createPlexStreamFileRequest(context, '/library/streams/20'); expect(request?.url).toBe( - 'https://plex.example.test:32400/library/streams/20?X-Plex-Client-Identifier=client-a', + 'https://plex.example.test:32400/library/streams/20?X-Plex-Client-Identifier=client-a&encoding=utf-8', ); expect(request?.url).not.toContain('secret-token'); expect(request?.token).toBe('secret-token'); diff --git a/packages/adapter-plex/src/stream-selection.ts b/packages/adapter-plex/src/stream-selection.ts index 0eed9dc..38d6280 100644 --- a/packages/adapter-plex/src/stream-selection.ts +++ b/packages/adapter-plex/src/stream-selection.ts @@ -174,7 +174,11 @@ export const resolvePlexExternalSubtitle = ( }; }; -/** 读取外挂字幕文件的请求,与读取元数据一样只把 Token 放在请求头里。 */ +/** + * 读取外挂字幕文件的请求,与读取元数据一样只把 Token 放在请求头里。 + * 带 `encoding=utf-8` 时 Plex Media Server 自己探测文件编码并转成 UTF-8,不带时按磁盘上的 + * 字节原样给出;GBK、Big5 等编码的字幕因此不依赖前面是否有 Cirvel 这样的反代转码。 + */ export const createPlexStreamFileRequest = ( context: PlexTimelineContext, key: string, @@ -182,6 +186,7 @@ export const createPlexStreamFileRequest = ( if (!PLEX_STREAM_KEY.test(key)) return undefined; const url = new URL(key, context.origin); for (const [name, value] of context.clientParameters) url.searchParams.set(name, value); + url.searchParams.set('encoding', 'utf-8'); return { url: url.toString(), token: context.token }; }; From 92df98ac166a3fde3c9e2f7ef8d22a4359a043ee Mon Sep 17 00:00:00 2001 From: InfinityPacer Date: Wed, 21 Oct 2026 10:15:03 +0800 Subject: [PATCH 4/4] docs(engine): state what dropping H.264 leading pictures can cost The leading-picture filter claimed the codec guarantees that pictures after the random access point never reference the dropped leading pictures. That holds for HEVC CRA, whose trailing pictures may not reference RASL or RADL pictures, but H.264 recovery-point I frames carry no such constraint, so a later picture can reference a dropped leading reference picture and decode with brief artifacts until the next random access point. The comment now says so and why the same rule still applies: kept leading pictures are undecodable at the start key and rejected by the muxer mid-stream. Behavior is unchanged. Co-Authored-By: Claude Opus 5.5 --- docs/architecture.md | 6 ++++-- packages/engine-mediabunny/src/leading-pictures.ts | 7 ++++++- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index de715a8..19a1aab 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -184,8 +184,10 @@ Input,健康输入可以保留同一 Range 会话和缓存。不能使用会 要求同一 GOP 内的包不早于上一 GOP 已写出的最大呈现时间。开放 GOP 的随机访问点(HEVC CRA、 带恢复点的 H.264 I 帧)在解码顺序上后面可能跟着呈现更早的前导图像,引擎在交给复用器前 按关键帧处理它们。输出起点关键帧的前导图像引用起点之前的图像,无法解码且早于新时间轴零点, -直接丢弃。中途关键帧的前导图像若早于上一 GOP 已写出的图像,则丢弃该关键帧的全部前导图像, -编码规范保证非前导图像不引用它们。关键帧本身早于上一 GOP 已写出的图像时(分段编码拼接处的 +直接丢弃。中途关键帧的前导图像若早于上一 GOP 已写出的图像,则丢弃该关键帧的全部前导图像。 +HEVC 规范保证 CRA 之后的尾随图像不引用前导图像,丢弃后其余图像仍可正确解码。H.264 没有这个 +约束,恢复点 I 帧之后的图像仍可能引用被丢弃的前导参考图像,由解码器隐藏错误,下一个随机访问点 +之前可能出现短暂花屏。保留这些前导图像在起点处同样无法解码,在中途会被复用器拒绝。关键帧本身早于上一 GOP 已写出的图像时(分段编码拼接处的 IDR 可见此排列),把它降为普通图像并入当前 GOP,全部图像保留,只少一个随机访问点。其余开放 GOP 原样输出,连续播放不丢帧。 diff --git a/packages/engine-mediabunny/src/leading-pictures.ts b/packages/engine-mediabunny/src/leading-pictures.ts index 0c4667b..db308ed 100644 --- a/packages/engine-mediabunny/src/leading-pictures.ts +++ b/packages/engine-mediabunny/src/leading-pictures.ts @@ -22,7 +22,12 @@ const MAX_HELD_LEADING_PICTURES = 64; * 更早的前导图像,它们可以引用该关键帧之前的图像。两种情况下丢弃该关键帧的全部前导图像: * - 输出起点的关键帧:之前的参考图像不在输出里,前导图像无法正确解码,时间也早于新时间轴零点; * - 中途关键帧的前导图像早于已写出图像的最大呈现时间:复用器按 GOP 推导解码时间,无法写入。 - * 编码规范保证非前导图像不引用前导图像,所以丢弃后其余图像仍可正确解码;其余情况原样输出。 + * 其余情况原样输出。 + * + * HEVC 规范保证 CRA 之后的尾随图像不引用前导图像(RASL 与 RADL),丢弃后其余图像仍可 + * 正确解码。H.264 没有这个约束,恢复点 I 帧之后的图像仍可能引用被丢弃的前导参考图像, + * 由解码器隐藏错误,下一个随机访问点之前可能出现短暂花屏。保留这些前导图像也无济于事:起点处 + * 它们本身缺参考,中途处复用器拒绝写入,所以两种编码按同一规则丢弃。 * * 关键帧本身早于已写出图像时(分段编码拼接处常见:短 GOP 末尾的图像呈现时间晚于下一个 IDR), * 复用器同样会拒绝。此时把它降为普通图像并入当前 GOP:解码顺序和全部图像都保留,