## 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.
138 lines
3.9 KiB
TypeScript
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();
|
|
});
|
|
});
|