Files
Piik/tests/video-frame-proof.test.ts
AAA 28827ec6a2 fix(media): improve sharing continuity and pause recovery (#431)
## Problem and outcome / 问题与结果

Sharing could stall at Browser encoder handoff or retain a pause after a video binding was replaced. Source/audio changes could also overlap offer/answer negotiation, while Native recovery could request redundant keyframes. This phase repairs those existing owners, drains capture output during bounded shutdown, and normalizes configured Site origins consistently with Browser serialization.

## Release notes

### 中文

这是一次稳定性更新,重点改善分享中的画面连续性,以及暂停、换源和恢复播放。

- 修复浏览器编码复用中的重复帧率限制和交接等待,减少由这些问题造成的卡顿。
- 修复换源和音频变更时的协商重叠,并防止已关闭连接的异步操作干扰恢复。
- 修复房主暂停期间重连后无法恢复播放,以及编码复用回退后仍停在暂停状态的问题;正常取消播放不再被当作媒体故障。
- 减少原生采集的重复关键帧请求,并修复停止采集时输出未排空导致的退出等待问题。
- 修复部分国际化域名和 IPv6 站点地址的识别,并改善窄屏下分享设置项的文字排版。

升级 Piik App 或自部署 Server 后刷新页面即可,无需修改配置。部分设备和网络环境下报告的开播失败、偶发断流仍需现场日志确认,本次更新不代表所有此类反馈均已解决。

### English

This stability update improves picture continuity and recovery when pausing, changing sources, or resuming playback.

- Fix duplicate frame-rate limiting and unnecessary handoff waits in Browser encoding reuse, reducing stalls caused by these issues.
- Serialize negotiation across source and audio changes, and prevent asynchronous work on closed connections from interfering with recovery.
- Fix playback remaining paused after reconnecting while the Host is paused, or after falling back from shared encoding. Normal playback cancellation is no longer treated as a media failure.
- Avoid redundant Native capture keyframe requests and drain pending capture output during bounded shutdown.
- Fix recognition of some internationalized domain names and IPv6 Site addresses, and improve sharing-setting labels on narrow screens.

Update Piik App or your self-hosted Server, then refresh the page. No configuration changes are required. Some device- and network-specific reports of sharing startup failure or intermittent stream loss still need matching diagnostics; this update does not claim to resolve every reported case.

## Verification / 验证

- `npm run check`: 76 test files / 1,152 tests, TypeScript and production Web build passed.
- Real Chrome VP8/H264 checks with two viewers passed live/paused profile changes, quiet-source recovery, source replacement and ordinary-sender fallback after producer failure.
- New pause-owner regressions fail against the previous implementation; actual Viewer effects preserve same-binding local pause and resume replacement bindings.
- Independent cumulative Browser and Native/config review found no confirmed release blocker.
- `npm run check:go`: formatting, Go tests/vet, Windows/Linux builds and Windows native capture compile/probes passed. Darwin packaging and Go race detection run on the native CI runners.
- Windows App H264 and VP8 Host gates passed decoded playback, live/paused quality changes, quiet/background-source recovery, source replacement, failure cleanup and subsequent sharing; experiment-owned processes, ports and profiles were cleaned up.
- Documentation, repository hygiene and release-note extraction passed.

## Impact / 影响

Patch release v1.6.7. Browser/server v23, Native control v9, capture v7 and SQLite schema 2 remain unchanged. Existing WebRTC adaptation and route policy remain unchanged; no quality floor is introduced. Interaction and Windows x86 prototypes are excluded. Managed Server restart interrupts active media and preserves room authority. Physical macOS/Linux capture, broad public-network endurance and unmatched reporter environments remain outside local acceptance.
2026-09-27 17:14:28 +08:00

138 lines
3.9 KiB
TypeScript

import { describe, expect, it, vi } from "vitest";
import { observeCompositedVideoFrame } from "../src/client/media/video-frame-proof.ts";
interface FakeVideo extends EventTarget {
srcObject: MediaStream | null;
readyState: number;
currentTime: number;
decodedFrames: number;
getVideoPlaybackQuality?: () => { totalVideoFrames: number };
requestVideoFrameCallback?: (callback: () => void) => number;
cancelVideoFrameCallback?: (handle: number) => void;
}
function fakeVideo(stream: MediaStream): FakeVideo {
const video = new EventTarget() as FakeVideo;
video.srcObject = stream;
video.readyState = 0;
video.currentTime = 0;
video.decodedFrames = 0;
video.getVideoPlaybackQuality = () => ({
totalVideoFrames: video.decodedFrames,
});
return video;
}
describe("composited video frame proof", () => {
it("binds requestVideoFrameCallback proof to the expected srcObject", () => {
const stream = {} as MediaStream;
const replacement = {} as MediaStream;
const video = fakeVideo(stream);
let callback: (() => void) | null = null;
video.requestVideoFrameCallback = (next) => {
callback = next;
return 7;
};
video.cancelVideoFrameCallback = vi.fn();
const onFrame = vi.fn();
const cancel = observeCompositedVideoFrame(
video as unknown as HTMLVideoElement,
stream,
onFrame,
);
video.readyState = 2;
video.srcObject = replacement;
callback!();
expect(onFrame).not.toHaveBeenCalled();
cancel();
expect(video.cancelVideoFrameCallback).not.toHaveBeenCalled();
});
it("keeps observing when the first composited callback arrives before readiness", () => {
const stream = {} as MediaStream;
const video = fakeVideo(stream);
const callbacks: Array<() => void> = [];
video.requestVideoFrameCallback = (callback) => {
callbacks.push(callback);
return callbacks.length;
};
const onFrame = vi.fn();
const cancel = observeCompositedVideoFrame(
video as unknown as HTMLVideoElement,
stream,
onFrame,
);
callbacks[0]!();
expect(onFrame).not.toHaveBeenCalled();
expect(callbacks).toHaveLength(2);
video.readyState = 2;
callbacks[1]!();
expect(onFrame).toHaveBeenCalledOnce();
cancel();
});
it("uses decoded progress plus readiness in the event fallback", () => {
const stream = {} as MediaStream;
const video = fakeVideo(stream);
const onFrame = vi.fn();
const cancel = observeCompositedVideoFrame(
video as unknown as HTMLVideoElement,
stream,
onFrame,
);
video.readyState = 2;
video.dispatchEvent(new Event("playing"));
expect(onFrame).not.toHaveBeenCalled();
video.decodedFrames = 1;
video.dispatchEvent(new Event("timeupdate"));
expect(onFrame).toHaveBeenCalledOnce();
video.decodedFrames = 2;
video.dispatchEvent(new Event("timeupdate"));
expect(onFrame).toHaveBeenCalledOnce();
cancel();
});
it("does not substitute media time for an available decoded-frame count", () => {
const stream = {} as MediaStream;
const video = fakeVideo(stream);
const onFrame = vi.fn();
const cancel = observeCompositedVideoFrame(
video as unknown as HTMLVideoElement,
stream,
onFrame,
);
video.readyState = 2;
video.currentTime = 1;
video.dispatchEvent(new Event("timeupdate"));
expect(onFrame).not.toHaveBeenCalled();
cancel();
});
it("uses media time only when decoded-frame count is unavailable", () => {
const stream = {} as MediaStream;
const video = fakeVideo(stream);
delete video.getVideoPlaybackQuality;
const onFrame = vi.fn();
const cancel = observeCompositedVideoFrame(
video as unknown as HTMLVideoElement,
stream,
onFrame,
);
video.readyState = 2;
video.currentTime = 1;
video.dispatchEvent(new Event("timeupdate"));
expect(onFrame).toHaveBeenCalledOnce();
cancel();
});
});