Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
072cbe7
feat(web-ui): add automatic deinterlacing with heuristic combing dete…
stackia Jul 3, 2026
577508c
feat(web-ui): replace bob with motion-adaptive bwdif deinterlacer
stackia Jul 3, 2026
8a0043f
feat(web-ui): detect field order (TFF/BFF) for deinterlacing
stackia Jul 3, 2026
a69d6d9
refactor(web-ui): align bwdif shader with FFmpeg reference
stackia Jul 3, 2026
ad4d73c
feat(web-ui): activate deinterlacing from codec metadata and fix stal…
stackia Jul 3, 2026
43b8d57
feat(web-ui): extend deinterlace resolution gate to all resolutions u…
stackia Jul 3, 2026
4c4b2ca
refactor(web-ui): move deinterlace layer into mpegts player core
stackia Jul 3, 2026
e5d47b2
feat(devlab): add 1080p-combed scan channel for heuristic-only deinte…
stackia Jul 3, 2026
fa706bc
refactor(web-ui): rename deinterlaceMode to deinterlace and simplify …
stackia Jul 3, 2026
6c11b9b
style(web-ui): fix biome formatting in deinterlace module
stackia Jul 3, 2026
10ddef8
fix(web-ui): address PR review feedback on deinterlace pipeline
stackia Jul 4, 2026
090cd7b
refactor(web-ui): replace sticky deinterlace with adaptive bidirectio…
stackia Jul 4, 2026
a06f2cc
fix(web-ui): gracefully handle WebGL context loss and fix reversion c…
stackia Jul 4, 2026
d07c40c
feat(web-ui): replace CPU interlace detection with GPU shader pipeline
stackia Jul 4, 2026
d2bb9bc
fix(web-ui): guard deinterlace pipeline before object creation
Codex Jul 4, 2026
6457f0a
refactor(web-ui): make deinterlace pipeline GPU-only and gate before …
stackia Jul 4, 2026
3383c11
refactor(web-ui): run bwdif only on interlaced verdict and make detec…
stackia Jul 4, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions tools/devlab/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ needs to support:
| HLS catchup | HTTP HLS VOD (`playseek`) | `/http` | all of the above |
| mpegts (RTSP) | RTSP TS live + catchup | `/rtsp` | `h264-mp2`, `hevc-aac` |
| mpegts (multicast) | RTP multicast live | `/rtp` | `h264-mp2`, `hevc-ac3`, `hevc-eac3` |
| mpegts (scan) | RTP multicast live | `/rtp` | 1080i / 1080p / 2160p (see below) |
| external file | RTP multicast (looped) | `/rtp` | whatever the `.ts` file contains |

- `h264-mp2` = H.264 video + MPEG-1/2 Layer II audio
Expand All @@ -36,6 +37,27 @@ Multicast channels use groups `239.255.0.20+` on `--mcast-port` (default 5004).
ffmpeg sends them via the OS default multicast route, and rtp2httpd joins them
without an explicit upstream interface.

## Interlace-scan channels (`mpegts (scan)`)

Channels for the web player's automatic deinterlacing (`MCAST_SCAN_CHANNELS`):

- **mcast 1080i-tff (h264-mp2)** — true interlaced TFF H.264: testsrc2 generated
at 50 fps, `tinterlace=interleave_top` weaves adjacent frames into the fields
of one 25 fps frame, so the moving pattern combs on every motion. The player's
heuristic detector must activate on this channel, the combing must disappear,
and the field-order vote must pick TFF.
- **mcast 1080i-bff (h264-mp2)** — same content weaved bottom-field-first
(`interleave_bottom`). The detector must activate and the field-order vote
must pick BFF; a TFF misdetection shows as juddery back-and-forth motion.
- **mcast 1080p-combed (h264-mp2)** — same weaved TFF content but encoded and
flagged as progressive (no `fieldorder`, no interlaced encoder flags), so the
codec-metadata hint stays silent. Only the heuristic comb detector can
activate deinterlacing — this validates the heuristic path end to end.
- **mcast 1080p (h264-mp2)** — progressive control at the resolution gate; the
detector must NOT activate (no false positive).
- **mcast 2160p (hevc-aac)** — above-1080 control; deinterlacing is gated off
regardless of content (browser HEVC support permitting, see Notes).

## Debugging a user-provided .ts file

Publish any external `.ts` as a multicast live channel (stream-copied, so the
Expand Down
68 changes: 60 additions & 8 deletions tools/devlab/devlab.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,20 @@
RTSP_PROFILES = ("h264-mp2", "hevc-aac")
# Profiles offered as multicast live channels.
MCAST_PROFILES = ("h264-mp2", "hevc-ac3", "hevc-eac3")
# Interlaced multicast channels for web-player deinterlacing work: 1080i TFF and
# BFF (both should trigger the heuristic detector, and the field-order vote must
# pick the right one) plus progressive controls at the gate boundary (1080p must
# not false-positive, 2160p must be gated off entirely). "tff-p" is weaved TFF
# content encoded/flagged as progressive — the codec metadata hint stays silent,
# so only the heuristic comb detector can activate deinterlacing.
# Tuple: (profile, size, scan) where scan is "tff" | "bff" | "tff-p" | "p".
MCAST_SCAN_CHANNELS = (
("h264-mp2", "1920x1080", "tff"),
("h264-mp2", "1920x1080", "bff"),
("h264-mp2", "1920x1080", "tff-p"),
("h264-mp2", "1920x1080", "p"),
("hevc-aac", "3840x2160", "p"),
)

# HLS live channels covering both segment specs: HLS-TS (MPEG-TS segments) and
# HLS-fMP4 (fragmented MP4: an init.mp4 + .m4s segments). fMP4 carries AAC audio
Expand Down Expand Up @@ -130,10 +144,15 @@ def _tail_file(path: str, max_bytes: int = 4096) -> str:
return ""


def video_args(profile: str) -> list[str]:
def video_args(profile: str, scan: str = "p") -> list[str]:
"""Encoder args for the video stream of *profile* (keyframe every second,
headers repeated so a client joining mid-stream can start decoding)."""
headers repeated so a client joining mid-stream can start decoding).
``scan`` is "p" (progressive) or "tff"/"bff" for true interlaced H.264."""
interlaced = scan in ("tff", "bff")
if profile.startswith("h264"):
x264_params = "keyint=25:min-keyint=25:scenecut=0:repeat-headers=1"
if interlaced:
x264_params += f":{scan}=1"
return [
"-c:v",
"libx264",
Expand All @@ -145,8 +164,9 @@ def video_args(profile: str) -> list[str]:
"high",
"-pix_fmt",
"yuv420p",
*(["-flags", "+ildct+ilme"] if interlaced else []),
"-x264-params",
"keyint=25:min-keyint=25:scenecut=0:repeat-headers=1",
x264_params,
"-b:v",
"2M",
]
Expand Down Expand Up @@ -210,12 +230,12 @@ def catchup_filter(profile: str, begin_epoch: int) -> str:
return f"{label},{seek},{elapsed}"


def lavfi_inputs(size: str = "1280x720") -> list[str]:
def lavfi_inputs(size: str = "1280x720", rate: int = 25) -> list[str]:
return [
"-f",
"lavfi",
"-i",
f"testsrc2=size={size}:rate=25",
f"testsrc2=size={size}:rate={rate}",
"-f",
"lavfi",
"-i",
Expand Down Expand Up @@ -687,13 +707,15 @@ def __init__(
profile: str | None = None,
ts_file: str | None = None,
size: str = "960x540",
scan: str = "p",
):
self.ffmpeg = ffmpeg
self.group = group
self.port = port
self.profile = profile
self.ts_file = ts_file
self.size = size
self.scan = scan
self.proc: subprocess.Popen[bytes] | None = None

def url(self) -> str:
Expand All @@ -706,13 +728,30 @@ def _cmd(self) -> list[str]:
# Stream-copy the original bitstream so the exact codecs are relayed.
return [*common, "-re", "-stream_loop", "-1", "-i", self.ts_file, "-c", "copy", "-f", "rtp_mpegts", out]
prof = self.profile or "h264-mp2"
if self.scan in ("tff", "bff", "tff-p"):
# True interlaced content with real motion between fields: generate at
# field rate (50fps), then tinterlace weaves adjacent frames into the
# two fields of one 25fps interlaced frame. The moving testsrc2
# pattern guarantees combing on any motion, which is exactly what the
# web player's heuristic detector needs to see.
field_order = "bff" if self.scan == "bff" else "tff"
mode = "interleave_top" if field_order == "tff" else "interleave_bottom"
vf = f"{live_filter(prof)},tinterlace=mode={mode}"
if self.scan != "tff-p":
# "tff-p": weaved combing but the bitstream stays flagged
# progressive, so the player's heuristic is the only trigger
vf += f",fieldorder={field_order}"
inputs = lavfi_inputs(self.size, rate=50)
else:
vf = live_filter(prof)
inputs = lavfi_inputs(self.size)
return [
*common,
"-re",
*lavfi_inputs(self.size),
*inputs,
"-vf",
live_filter(prof),
*video_args(prof),
vf,
*video_args(prof, scan="p" if self.scan == "tff-p" else self.scan),
*audio_args(prof),
"-f",
"rtp_mpegts",
Expand Down Expand Up @@ -863,6 +902,19 @@ def main() -> int:
s = MulticastLive(args.ffmpeg, group, args.mcast_port, profile=prof)
senders.append(s)
mcast_channels.append(("mpegts (multicast)", mcast_labels.get(prof, f"mcast ({prof})"), s.url()))
for prof, size, scan in MCAST_SCAN_CHANNELS:
group = f"239.255.0.{octet}"
octet += 1
s = MulticastLive(args.ffmpeg, group, args.mcast_port, profile=prof, size=size, scan=scan)
senders.append(s)
height = size.split("x")[1]
if scan == "p":
scan_label = f"{height}p"
elif scan == "tff-p":
scan_label = f"{height}p-combed"
else:
scan_label = f"{height}i-{scan}"
mcast_channels.append(("mpegts (scan)", f"mcast {scan_label} ({prof})", s.url()))
for path in args.ts_file:
if not os.path.isfile(path):
print(f"WARNING: --ts-file not found, skipping: {path}", file=sys.stderr)
Expand Down
10 changes: 10 additions & 0 deletions web-ui/src/components/player/settings-dropdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ interface SettingsDropdownProps {
onThemeChange: (theme: ThemeMode) => void;
seamlessSwitch: boolean;
onSeamlessSwitchChange: (enabled: boolean) => void;
deinterlace: boolean;
onDeinterlaceChange: (enabled: boolean) => void;
}

const localeOptions: Array<{ value: Locale; label: string }> = [
Expand All @@ -36,6 +38,8 @@ function SettingsDropdownComponent({
onThemeChange,
seamlessSwitch,
onSeamlessSwitchChange,
deinterlace,
onDeinterlaceChange,
}: SettingsDropdownProps) {
const t = usePlayerTranslation(locale);
const [isOpen, setIsOpen] = useState(false);
Expand Down Expand Up @@ -109,6 +113,12 @@ function SettingsDropdownComponent({
aria-label={t("seamlessSwitch")}
/>
</div>

{/* Automatic deinterlacing (heuristic detection, ≤1080 content only) */}
<div className="flex items-center justify-between px-1">
<span className="text-xs font-medium text-muted-foreground">{t("deinterlace")}</span>
<Switch checked={deinterlace} onCheckedChange={onDeinterlaceChange} aria-label={t("deinterlace")} />
</div>
</div>
</div>
)}
Expand Down
66 changes: 53 additions & 13 deletions web-ui/src/components/player/video-player.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ interface VideoPlayerProps {
onToggleSidebar?: () => void;
onFullscreenToggle?: () => void;
seamlessSwitch?: boolean;
deinterlace?: boolean;
activeSourceIndex?: number;
onSourceChange?: (index: number) => void;
onPlaybackStarted?: () => void;
Expand Down Expand Up @@ -145,6 +146,7 @@ export function VideoPlayer({
onToggleSidebar,
onFullscreenToggle,
seamlessSwitch = true,
deinterlace = true,
activeSourceIndex = 0,
onSourceChange,
onPlaybackStarted,
Expand All @@ -153,6 +155,8 @@ export function VideoPlayer({

const slotAVideoRef = useRef<HTMLVideoElement>(null);
const slotBVideoRef = useRef<HTMLVideoElement>(null);
const slotACanvasRef = useRef<HTMLCanvasElement>(null);
const slotBCanvasRef = useRef<HTMLCanvasElement>(null);
const slotAPlayerRef = useRef<Player | null>(null);
const slotBPlayerRef = useRef<Player | null>(null);
const slotLiveStateRef = useRef<Record<SlotId, boolean>>({ a: true, b: true });
Expand All @@ -166,8 +170,16 @@ export function VideoPlayer({
const skipNextSegmentsLoadRef = useRef(false);

const slotVideoRef = (id: SlotId) => (id === "a" ? slotAVideoRef : slotBVideoRef);
const slotCanvasRef = (id: SlotId) => (id === "a" ? slotACanvasRef : slotBCanvasRef);
const slotPlayerRef = (id: SlotId) => (id === "a" ? slotAPlayerRef : slotBPlayerRef);

const [deinterlaceActiveSlots, setDeinterlaceActiveSlots] = useState<Record<SlotId, boolean>>({
a: false,
b: false,
});
const setDeinterlaceActiveSlot = (slotId: SlotId, active: boolean) =>
setDeinterlaceActiveSlots((prev) => (prev[slotId] === active ? prev : { ...prev, [slotId]: active }));

const getActiveSlotId = () => activeSlotIdRef.current;
const getActiveVideo = () => slotVideoRef(getActiveSlotId()).current;
const getActivePlayer = () => slotPlayerRef(getActiveSlotId()).current;
Expand Down Expand Up @@ -351,6 +363,7 @@ export function VideoPlayer({
const destroySlot = useEffectEvent((slotId: SlotId) => {
slotPlayerRef(slotId).current?.destroy();
slotPlayerRef(slotId).current = null;
setDeinterlaceActiveSlot(slotId, false);
});

const stopPendingTransition = useEffectEvent(() => {
Expand Down Expand Up @@ -558,6 +571,8 @@ export function VideoPlayer({

const p = createPlayer(video, {
wasmDecoders: { mp2: mp2WasmUrl },
deinterlaceCanvas: slotCanvasRef(slotId).current ?? undefined,
deinterlace,
});
p.on("error", (e) => {
if (slotPlayerRef(slotId).current === p) {
Expand Down Expand Up @@ -585,6 +600,11 @@ export function VideoPlayer({
handleAudioSuspended();
}
});
p.on("deinterlace-active-change", (active) => {
if (slotPlayerRef(slotId).current === p) {
setDeinterlaceActiveSlot(slotId, active);
}
});
applyPlayerSettings(p);
slotPlayerRef(slotId).current = p;
return p;
Expand Down Expand Up @@ -730,6 +750,9 @@ export function VideoPlayer({

const pendingTransition = { gen, slotId: pendingId, player: pendingPlayer, startedAt: performance.now() };
pendingTransitionRef.current = pendingTransition;
// The pending slot's player resets its interlace verdict on loadSegments and
// starts detecting while hidden, so an interlaced verdict can be ready the
// moment the switch completes (no combing flash on channel change)
pendingPlayer.loadSegments(newSegments);

if (shouldAutoPlayRef.current) {
Expand All @@ -751,6 +774,11 @@ export function VideoPlayer({
};
}, []);

useEffect(() => {
slotAPlayerRef.current?.setDeinterlace(deinterlace);
slotBPlayerRef.current?.setDeinterlace(deinterlace);
}, [deinterlace]);

useEffect(() => {
if (!seamlessSwitch) {
stopPendingTransition();
Expand Down Expand Up @@ -1198,19 +1226,31 @@ export function VideoPlayer({
>
<div className="relative aspect-video h-auto max-h-full w-full max-w-full overflow-hidden [@container_video_(max-aspect-ratio:_16/9)]:h-auto [@container_video_(max-aspect-ratio:_16/9)]:w-full [@container_video_(min-aspect-ratio:_16/9)]:h-full [@container_video_(min-aspect-ratio:_16/9)]:w-auto">
{(visibleSlotId === "a" ? (["b", "a"] as const) : (["a", "b"] as const)).map((slotId) => (
// biome-ignore lint/a11y/useMediaCaption: live streaming video has no caption tracks
<video
key={slotId}
ref={slotId === "a" ? slotAVideoRef : slotBVideoRef}
className={clsx(
"absolute inset-0 size-full min-h-0 min-w-0 object-fill",
visibleSlotId !== slotId && "invisible pointer-events-none",
)}
playsInline
webkit-playsinline="true"
x5-playsinline="true"
onClick={visibleSlotId === slotId ? handleVideoClick : undefined}
/>
<div key={slotId} className="contents">
{/* biome-ignore lint/a11y/useMediaCaption: live streaming video has no caption tracks */}
<video
ref={slotId === "a" ? slotAVideoRef : slotBVideoRef}
className={clsx(
"absolute inset-0 size-full min-h-0 min-w-0 object-fill",
// Background slot: opacity (not visibility) keeps requestVideoFrameCallback
// firing so interlace detection can warm up during seamless switch.
visibleSlotId !== slotId && "opacity-0 pointer-events-none",
// Active slot: hide raw video behind the deinterlaced canvas output
visibleSlotId === slotId && deinterlaceActiveSlots[slotId] && "opacity-0",
)}
playsInline
webkit-playsinline="true"
x5-playsinline="true"
onClick={visibleSlotId === slotId ? handleVideoClick : undefined}
/>
<canvas
ref={slotId === "a" ? slotACanvasRef : slotBCanvasRef}
className={clsx(
"pointer-events-none absolute inset-0 size-full min-h-0 min-w-0",
(visibleSlotId !== slotId || !deinterlaceActiveSlots[slotId]) && "hidden",
)}
/>
</div>
))}
</div>

Expand Down
3 changes: 3 additions & 0 deletions web-ui/src/i18n/player.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,7 @@ const base: TranslationDict = {
themeLight: "Light",
themeDark: "Dark",
seamlessSwitch: "Seamless switch",
deinterlace: "Auto Deinterlacing",
};

const zhHans: TranslationDict = {
Expand Down Expand Up @@ -196,6 +197,7 @@ const zhHans: TranslationDict = {
themeLight: "浅色",
themeDark: "深色",
seamlessSwitch: "无缝换台",
deinterlace: "自动反交错",
};

// 繁體中文(偏好香港用語)
Expand Down Expand Up @@ -295,6 +297,7 @@ const zhHant: TranslationDict = {
themeLight: "淺色",
themeDark: "深色",
seamlessSwitch: "無縫換台",
deinterlace: "自動反交錯",
};

export const translations: Record<Locale, TranslationDict> = {
Expand Down
1 change: 1 addition & 0 deletions web-ui/src/lib/player-storage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ export const [getLastChannelId, saveLastChannelId] = createStore<string | null>(
);
export const [getSidebarVisible, saveSidebarVisible] = createStore("rtp2httpd-player-sidebar-visible", true);
export const [getSeamlessSwitch, saveSeamlessSwitch] = createStore("rtp2httpd-player-seamless-switch", true);
export const [getDeinterlace, saveDeinterlace] = createStore("rtp2httpd-player-deinterlace", true);
export const [getVolume, saveVolume] = createStore("rtp2httpd-player-volume", 1);
export const [getMuted, saveMuted] = createStore("rtp2httpd-player-muted", false);

Expand Down
10 changes: 10 additions & 0 deletions web-ui/src/mpegts/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,13 @@ export interface PlayerConfig {
headers: Record<string, string> | undefined;
/** Frontend log level: 0=FATAL, 1=ERROR, 2=WARN, 3=INFO, 4=DEBUG/VERBOSE. */
logLevel: number | undefined;

/** Overlay canvas the player draws deinterlaced frames onto. Omit to disable deinterlacing entirely.
* The player never touches the canvas' style/visibility — drive that from the
* `deinterlace-active-change` event. */
deinterlaceCanvas: HTMLCanvasElement | undefined;
/** Deinterlacing enabled. @default true */
deinterlace: boolean;
}

export const defaultConfig: PlayerConfig = {
Expand All @@ -40,6 +47,9 @@ export const defaultConfig: PlayerConfig = {
referrerPolicy: undefined,
headers: undefined,
logLevel: undefined,

deinterlaceCanvas: undefined,
deinterlace: true,
};

export function createDefaultConfig(): PlayerConfig {
Expand Down
Loading
Loading