Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
104 changes: 47 additions & 57 deletions web-ui/src/mpegts/render/filters/bwdif.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ import { type RenderParams, registerFilter, type VideoFilter } from "./types";
* on static high-frequency detail fall through to the spatial path, which
* flickers near-Nyquist stripes (reproducible with ffplay -vf bwdif; yadif
* survives only thanks to its edge-directed interpolation, which bwdif
* dropped). See the constant's comment for the tuning rationale.
* dropped).
* - Chroma cannot get true per-plane bwdif: the browser upsamples 4:2:0
* interlaced chroma progressively, baking field-interleaved color combing
* (row periods 2 and 4) into the weaved RGB on ALL rows, kept lines
Expand All @@ -39,43 +39,54 @@ import { type RenderParams, registerFilter, type VideoFilter } from "./types";
* Frame-boundary rows (y<4 or y+5>h) use FFmpeg's FILTER_EDGE variant: plain
* (c+e)/2 spatial average with the spatial check only where its ±2 taps fit,
* instead of running the wide filter into clamped (duplicated) edge rows.
*
* Shader uniforms:
* - u_prev / u_cur / u_next: weaved frames N-1 / N (being deinterlaced) / N+1.
* - u_height: frame height in pixels.
* - u_keepField: 0 = keep even rows (top field), 1 = bottom field.
* - u_secondField: 1 when rendering the temporally second field of the frame.
* - u_spatialOnly: 1 when the ring has no real history yet (just started /
* primed while paused); temporal terms would weave duplicated frames, so
* interpolate spatially instead.
*
* WEAVE_TOLERANCE (full-range 8-bit code units): static near-Nyquist stripes
* (resolution test wedges) carry ~10-13 codes of per-frame encoder ringing;
* anything slipping past the gate into the spatial path inverts such stripes
* (bwdif interpolates strictly vertically, unclamped where the spatial check
* widens FILTER2). Weaving real motion is self-limiting — residual comb
* amplitude is bounded by the temporal diff the gate measured (~5%), masked by
* motion — so raising it buys nothing; lowering it re-introduces flicker on
* fine static detail. Validate changes against low-contrast slow motion
* (scrolling credits, dark pans).
*
* prev2/next2 read rows at the MISSING parity that are the temporally
* previous/next fields of the field being rendered (see FFmpeg bwdif): for the
* first field the missing field is newer in prev, older in cur; for the second
* it is older in cur, newer in next.
*/

const FRAGMENT_SHADER = `#version 300 es
precision highp float;

uniform sampler2D u_prev; // frame N-1 (weaved)
uniform sampler2D u_cur; // frame N (the frame being deinterlaced)
uniform sampler2D u_next; // frame N+1 (weaved)
uniform float u_height; // frame height in pixels
uniform float u_keepField; // 0.0 = render top field (even rows kept), 1.0 = bottom field
uniform float u_secondField; // 1.0 = this is the temporally second field of the frame
uniform float u_spatialOnly; // 1.0 = no real frame history yet: spatial-only interpolation
uniform sampler2D u_prev;
uniform sampler2D u_cur;
uniform sampler2D u_next;
uniform float u_height;
uniform float u_keepField;
uniform float u_secondField;
uniform float u_spatialOnly;

in vec2 v_texCoord;
out vec4 outColor;

// Weave-gate motion threshold for FILTER1, in full-range 8-bit code units.
// Empirical: static near-Nyquist stripes (resolution test wedges) carry
// ~10-13 codes of per-frame encoder ringing, and anything that slips past
// this gate into the spatial path inverts such stripes (bwdif interpolates
// strictly vertically, unclamped there because the spatial check legitimately
// widens FILTER2). The cost of weaving real motion is self-limiting: the
// residual comb amplitude is bounded by the temporal diff the gate measured,
// i.e. ~this many codes (~5%), masked by motion. Raising it further buys
// nothing; lowering it re-introduces flicker on fine static detail. Validate
// changes against low-contrast slow motion (scrolling credits, dark pans).
const float WEAVE_TOLERANCE = 10.0;

// BT.709 luma/chroma split (only mixed and unmixed inside the shader, so the
// exact matrix does not matter for the round trip)
float lumaOf(vec3 rgb) {
return dot(rgb, vec3(0.2126, 0.7152, 0.0722));
}

// Snap a reconstructed luma back onto the 8-bit code grid. FFmpeg's temporal
// diffs compare integer code values; the RGB->luma dot product leaves
// sub-code float residue that would otherwise register as spurious motion.
// Snap luma onto the 8-bit code grid (FFmpeg's diffs are integer; RGB round-trip
// residue must not read as motion).
float quant8(float v) {
return floor(v * 255.0 + 0.5);
}
Expand All @@ -94,10 +105,6 @@ float rowLuma(sampler2D t, float dy) {
return lumaOf(rowRGB(t, dy));
}

// prev2/next2: frames whose rows at the MISSING parity are the temporally
// previous/next fields of the field being rendered (see FFmpeg bwdif).
// First field of the frame: the missing field is newer in prev, older in cur.
// Second field: the missing field is older in cur, newer in next.
float prev2Luma(float dy) {
return u_secondField < 0.5 ? rowLuma(u_prev, dy) : rowLuma(u_cur, dy);
}
Expand All @@ -106,28 +113,23 @@ float next2Luma(float dy) {
return u_secondField < 0.5 ? rowLuma(u_cur, dy) : rowLuma(u_next, dy);
}

// FFmpeg bwdif FILTER1 + SPAT_CHECK + FILTER_LINE/FILTER_EDGE + FILTER2 in
// float form (integer coefficients are /8192 fixed-point in the reference).
// isEdge selects FFmpeg's filter_edge variant for rows whose wide-filter taps
// (rows +-3, +-4) would cross the frame boundary; spatCheck mirrors its spat flag.
// FFmpeg bwdif FILTER1 + SPAT_CHECK + FILTER_LINE/FILTER_EDGE + FILTER2 in float
// form (integer coeffs are /8192 fixed-point). isEdge = wide taps cross the
// boundary; spatCheck = the ±2 spatial-check taps are in-frame.
float bwdifLuma(bool isEdge, bool spatCheck) {
float c = rowLuma(u_cur, -1.0);
float e = rowLuma(u_cur, 1.0);
float p2_0 = prev2Luma(0.0);
float n2_0 = next2Luma(0.0);
float d = 0.5 * (p2_0 + n2_0);

// FILTER1: temporal difference, evaluated on the 8-bit code grid like the
// integer reference — sub-code float residue from the RGB round trip must
// not count as motion — and gated by WEAVE_TOLERANCE instead of FFmpeg's
// "diff < 1 code" weave condition (see the constant's comment).
// FILTER1 temporal diff on the code grid, gated by WEAVE_TOLERANCE
float td0 = abs(quant8(p2_0) - quant8(n2_0));
float td1 = 0.5 * (abs(quant8(rowLuma(u_prev, -1.0)) - quant8(c)) + abs(quant8(rowLuma(u_prev, 1.0)) - quant8(e)));
float td2 = 0.5 * (abs(quant8(rowLuma(u_next, -1.0)) - quant8(c)) + abs(quant8(rowLuma(u_next, 1.0)) - quant8(e)));
float diffCodes = max(max(td0 * 0.5, td1), td2);

// No temporal change at this pixel: pure temporal average (weave) — this is
// what preserves full vertical resolution in static areas
// No motion: weave (temporal average), preserving full vertical resolution
if (diffCodes <= WEAVE_TOLERANCE) {
return d;
}
Expand All @@ -137,8 +139,7 @@ float bwdifLuma(bool isEdge, bool spatCheck) {

float interpol;
if (isEdge) {
// FILTER_EDGE: spatial check only when the ±2 taps are in-frame, then a
// plain spatial average — the reference never runs the wide filters here
// FILTER_EDGE: spatial check (±2 taps) then plain (c+e)/2
if (spatCheck) {
float b = 0.5 * (prev2Luma(-2.0) + next2Luma(-2.0)) - c;
float f = 0.5 * (prev2Luma(2.0) + next2Luma(2.0)) - e;
Expand All @@ -164,11 +165,10 @@ float bwdifLuma(bool isEdge, bool spatCheck) {
float mn = min(min(de, dc), max(b, f));
diff = max(max(diff, mn), -mx);

// FILTER_LINE
// FILTER_LINE: Weston 3-field HF term when detail crosses the gap, else LF
float curM3 = rowLuma(u_cur, -3.0);
float curP3 = rowLuma(u_cur, 3.0);
if (abs(c - e) > td0) {
// High-frequency content across the gap: Weston 3-field HF term + LF term
float hf = (5570.0 * (p2_0 + n2_0) - 3801.0 * (p2_m2 + n2_m2 + p2_p2 + n2_p2) +
1016.0 * (prev2Luma(-4.0) + next2Luma(-4.0) + prev2Luma(4.0) + next2Luma(4.0))) /
4.0;
Expand All @@ -182,8 +182,6 @@ float bwdifLuma(bool isEdge, bool spatCheck) {
return clamp(interpol, d - diff, d + diff);
}

// Motion measure for the chroma path: reuse bwdif's FILTER1 temporal diff on
// the chroma channels of the same rows the luma filter reads
float chromaMotion() {
vec2 cCur = chromaOf(rowRGB(u_cur, 0.0));
vec2 cPrev = chromaOf(rowRGB(u_prev, 0.0));
Expand All @@ -197,37 +195,29 @@ void main() {
float row = v_texCoord.y * u_height;
float parity = mod(floor(row), 2.0);

// FFmpeg row dispatch: rows y<4 or y+5>h use filter_edge (wide taps would
// cross the boundary); the spatial check needs the ±2 taps in-frame
// Rows y<4 or y+5>h use filter_edge; the spatial check needs the ±2 taps in-frame
bool isEdge = row < 4.0 || row + 5.0 > u_height;
bool spatCheck = row >= 2.0 && row + 3.0 <= u_height;

float luma;
if (parity == u_keepField) {
// Kept field line: pass through
luma = rowLuma(u_cur, 0.0);
luma = rowLuma(u_cur, 0.0); // kept field: pass through
} else if (u_spatialOnly > 0.5) {
// No real history in the ring (just started / primed while paused):
// temporal terms would see duplicated frames and weave the combing
// through. Interpolate spatially from the kept field only.
// No history yet: spatial-only interpolation from the kept field
float c = rowLuma(u_cur, -1.0);
float e = rowLuma(u_cur, 1.0);
luma = isEdge ? 0.5 * (c + e) : (5077.0 * (c + e) - 981.0 * (rowLuma(u_cur, -3.0) + rowLuma(u_cur, 3.0))) / 8192.0;
} else {
luma = bwdifLuma(isEdge, spatCheck);
}

// Chroma: motion-adaptive. Static pixels keep original chroma (full detail,
// analogous to bwdif's weave path); moving pixels blend toward a vertical
// [1,2,2,2,1]/8 low-pass that nulls the period-2/period-4 field-interleaved
// chroma combing the progressive 4:2:0 upsample bakes into ALL rows.
// Motion-adaptive chroma: static keeps original, moving blends toward a
// vertical [1,2,2,2,1]/8 low-pass that nulls the baked-in 4:2:0 field comb
vec2 chromaOrig = chromaOf(rowRGB(u_cur, 0.0));
vec2 chromaLP = (chromaOf(rowRGB(u_cur, -2.0)) + 2.0 * chromaOf(rowRGB(u_cur, -1.0)) + 2.0 * chromaOrig +
2.0 * chromaOf(rowRGB(u_cur, 1.0)) + chromaOf(rowRGB(u_cur, 2.0))) /
8.0;
// Ramp: fully original below ~1/255 motion, fully low-passed above ~4/255.
// Without real history chromaMotion() compares duplicated frames (always 0),
// so force the low-pass — the combing is baked in regardless of motion.
// No history: chromaMotion() sees duplicated frames (0), so force the low-pass
float t = u_spatialOnly > 0.5 ? 1.0 : smoothstep(1.0 / 255.0, 4.0 / 255.0, chromaMotion());
vec2 chroma = mix(chromaOrig, chromaLP, t);

Expand Down
4 changes: 3 additions & 1 deletion web-ui/src/mpegts/render/filters/sharpen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ import { type RenderParams, registerFilter, type VideoFilter } from "./types";
*
* Unlike source stage filters (passthrough/bwdif), the input is a framebuffer
* texture rather than a DOM video upload, hence FRAMEBUFFER_VERTEX_SHADER.
*
* CONTRAST/SATURATION are a mild tone tweak applied together with the sharpen;
* part of the enhancement look.
*/

const FRAGMENT_SHADER = `#version 300 es
Expand All @@ -21,7 +24,6 @@ out vec4 outColor;

const vec3 LUMA = vec3(0.2126, 0.7152, 0.0722);

// Tone tweak applied together with sharpening; part of the enhancement look.
const float CONTRAST = 1.04;
const float SATURATION = 1.03;
const float SHARPEN_STRENGTH = 0.22;
Expand Down
13 changes: 7 additions & 6 deletions web-ui/src/mpegts/render/interlace-detector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,13 @@ void main() {
* Field-order pass — renders to a half-height FBO (one output row per even
* input row). Compares the TFF and BFF temporal midpoint predictions for each
* row pair. Output: R = errTff, G = errBff (per-row mean absolute error).
*
* rowLuma samples at rowF + 0.5, the texel CENTER: the video textures use
* LINEAR filtering, so sampling at a texel boundary would average two adjacent
* rows — blending the two fields and corrupting both hypotheses.
*
* main maps v_texCoord.y in [0,1] to half-height FBO rows, each corresponding to
* an even source row (0, 2, 4, ...).
*/
const FIELD_ORDER_FRAGMENT_SHADER = `${GLSL_LUMA_PRELUDE}
uniform sampler2D u_prev;
Expand All @@ -145,16 +152,10 @@ in vec2 v_texCoord;
out vec4 outColor;

float rowLuma(sampler2D tex, float x, float rowF) {
// +0.5: sample at the texel CENTER of that row. The video textures use
// LINEAR filtering, so sampling at rowF exactly (a texel boundary) would
// average two adjacent rows — i.e. blend the two fields together and
// corrupt both hypotheses.
return lumaAt(tex, vec2(x, (rowF + 0.5) / u_height));
}

void main() {
// v_texCoord.y in [0,1] maps to half-height FBO rows; each corresponds to
// an even row of the source frame (0, 2, 4, ...).
float row = floor(v_texCoord.y * (u_height * 0.5)) * 2.0;
float x = v_texCoord.x;

Expand Down
Loading