Skip to content

Commit ac02f9b

Browse files
authored
fix: draw clip filters in the preview as the render applies them (#265)
1 parent c6150eb commit ac02f9b

4 files changed

Lines changed: 286 additions & 11 deletions

File tree

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
import type { ResolvedClip } from "@schemas";
2+
import { defaultFilterVert, Filter, GlProgram, GpuProgram, UniformGroup } from "pixi.js";
3+
4+
/**
5+
* Recolours a clip with the render's brightness, saturation and negate maths.
6+
*
7+
* Per unpremultiplied pixel: contrast `f · (c − mid) + mid`, then `+ brightness`, each clamped; then saturation
8+
* around the HSP brightness `√(.299r² + .587g² + .114b²)`; then inversion.
9+
*/
10+
11+
interface ColorParams {
12+
/** −255…255 */
13+
contrast?: number;
14+
brightness?: number;
15+
saturation?: number;
16+
negate?: boolean;
17+
}
18+
19+
export const COLOR_FILTERS: Partial<Record<NonNullable<ResolvedClip["filter"]>, ColorParams>> = {
20+
boost: { saturation: 1.75 },
21+
contrast: { contrast: 15 },
22+
darken: { brightness: -0.15 },
23+
greyscale: { saturation: 0 },
24+
lighten: { brightness: 0.15 },
25+
muted: { contrast: 85, brightness: -0.05, saturation: 0.6 },
26+
negative: { negate: true }
27+
};
28+
29+
const MID = "0.5019608"; // 128 / 255
30+
const HSP = "0.299, 0.587, 0.114";
31+
32+
const glFragment = `
33+
in vec2 vTextureCoord;
34+
out vec4 finalColor;
35+
36+
uniform sampler2D uTexture;
37+
uniform float uContrast;
38+
uniform float uBrightness;
39+
uniform float uSaturation;
40+
uniform float uNegate;
41+
42+
void main(void)
43+
{
44+
vec4 color = texture(uTexture, vTextureCoord);
45+
if (color.a == 0.0) {
46+
finalColor = color;
47+
return;
48+
}
49+
vec3 rgb = clamp(uContrast * (color.rgb / color.a - ${MID}) + ${MID}, 0.0, 1.0);
50+
rgb = clamp(rgb + uBrightness, 0.0, 1.0);
51+
float p = sqrt(dot(rgb * rgb, vec3(${HSP})));
52+
rgb = clamp(p + (rgb - p) * uSaturation, 0.0, 1.0);
53+
rgb = mix(rgb, 1.0 - rgb, uNegate);
54+
finalColor = vec4(rgb * color.a, color.a);
55+
}
56+
`;
57+
58+
const gpuSource = `
59+
struct GlobalFilterUniforms {
60+
uInputSize:vec4<f32>,
61+
uInputPixel:vec4<f32>,
62+
uInputClamp:vec4<f32>,
63+
uOutputFrame:vec4<f32>,
64+
uGlobalFrame:vec4<f32>,
65+
uOutputTexture:vec4<f32>,
66+
};
67+
68+
struct ColorUniforms {
69+
uContrast:f32,
70+
uBrightness:f32,
71+
uSaturation:f32,
72+
uNegate:f32,
73+
};
74+
75+
@group(0) @binding(0) var<uniform> gfu: GlobalFilterUniforms;
76+
@group(0) @binding(1) var uTexture: texture_2d<f32>;
77+
@group(0) @binding(2) var uSampler : sampler;
78+
@group(1) @binding(0) var<uniform> colorUniforms : ColorUniforms;
79+
80+
struct VSOutput {
81+
@builtin(position) position: vec4<f32>,
82+
@location(0) uv : vec2<f32>
83+
};
84+
85+
fn filterVertexPosition(aPosition:vec2<f32>) -> vec4<f32>
86+
{
87+
var position = aPosition * gfu.uOutputFrame.zw + gfu.uOutputFrame.xy;
88+
position.x = position.x * (2.0 / gfu.uOutputTexture.x) - 1.0;
89+
position.y = position.y * (2.0*gfu.uOutputTexture.z / gfu.uOutputTexture.y) - gfu.uOutputTexture.z;
90+
return vec4(position, 0.0, 1.0);
91+
}
92+
93+
fn filterTextureCoord( aPosition:vec2<f32> ) -> vec2<f32>
94+
{
95+
return aPosition * (gfu.uOutputFrame.zw * gfu.uInputSize.zw);
96+
}
97+
98+
@vertex
99+
fn mainVertex(@location(0) aPosition : vec2<f32>) -> VSOutput {
100+
return VSOutput(filterVertexPosition(aPosition), filterTextureCoord(aPosition));
101+
}
102+
103+
@fragment
104+
fn mainFragment(@location(0) uv: vec2<f32>, @builtin(position) position: vec4<f32>) -> @location(0) vec4<f32> {
105+
let color = textureSample(uTexture, uSampler, uv);
106+
if (color.a == 0.0) {
107+
return color;
108+
}
109+
var rgb = saturate(colorUniforms.uContrast * (color.rgb / color.a - ${MID}) + ${MID});
110+
rgb = saturate(rgb + colorUniforms.uBrightness);
111+
let p = sqrt(dot(rgb * rgb, vec3<f32>(${HSP})));
112+
rgb = saturate(p + (rgb - p) * colorUniforms.uSaturation);
113+
rgb = select(rgb, 1.0 - rgb, colorUniforms.uNegate > 0.5);
114+
return vec4<f32>(rgb * color.a, color.a);
115+
}
116+
`;
117+
118+
export class ColorFilter extends Filter {
119+
constructor({ contrast = 0, brightness = 0, saturation = 1, negate = false }: ColorParams) {
120+
const glProgram = GlProgram.from({ vertex: defaultFilterVert, fragment: glFragment, name: "color-filter" });
121+
const gpuProgram = GpuProgram.from({
122+
vertex: { source: gpuSource, entryPoint: "mainVertex" },
123+
fragment: { source: gpuSource, entryPoint: "mainFragment" }
124+
});
125+
const colorUniforms = new UniformGroup({
126+
uContrast: { value: (259 * (contrast + 255)) / (255 * (259 - contrast)), type: "f32" },
127+
uBrightness: { value: brightness, type: "f32" },
128+
uSaturation: { value: saturation, type: "f32" },
129+
uNegate: { value: negate ? 1 : 0, type: "f32" }
130+
});
131+
super({ glProgram, gpuProgram, resources: { colorUniforms }, padding: 0 });
132+
}
133+
}

‎src/components/canvas/players/player.ts‎

Lines changed: 70 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@ import * as pixi from "pixi.js";
2323

2424
import { Entity } from "../../../core/shared/entity";
2525

26+
import { COLOR_FILTERS, ColorFilter } from "./color-filter";
27+
2628
/**
2729
* Tracks a merge field binding for a specific property path.
2830
* Used to restore placeholders on export for properties that haven't changed.
@@ -52,6 +54,21 @@ export enum PlayerType {
5254
RichCaption = "rich-caption"
5355
}
5456

57+
// The render filters clips it draws as images and leaves those it plays as video untouched.
58+
const FILTERED_PLAYERS = new Set([
59+
PlayerType.Image,
60+
PlayerType.Text,
61+
PlayerType.RichText,
62+
PlayerType.Shape,
63+
PlayerType.Caption,
64+
PlayerType.Html,
65+
PlayerType.Svg,
66+
PlayerType.TextToImage
67+
]);
68+
69+
// pixi's Gaussian at this strength spreads as far as the render's radius-10 box blur (σ ≈ 6.06 px).
70+
const CLIP_BLUR_STRENGTH = 4.69;
71+
5572
/**
5673
* Base class for all visual content players in the canvas.
5774
*
@@ -101,6 +118,9 @@ export abstract class Player extends Entity {
101118
private wipeFilter: WipeFilter | null = null;
102119
private zoomBlurBuilder?: KeyframeBuilder;
103120
private zoomBlurFilter: pixi.BlurFilter | null = null;
121+
private zoomBlurRadius = 0;
122+
private clipFilterName: ResolvedClip["filter"];
123+
private clipFilter: pixi.Filter | null = null;
104124
private rotationOrigin?: { x: KeyframeBuilder; y: KeyframeBuilder };
105125

106126
private wipeMask: pixi.Graphics | null;
@@ -126,6 +146,7 @@ export abstract class Player extends Entity {
126146
this.contentContainer = new pixi.Container();
127147
this.lumaWrapper.addChild(this.contentContainer);
128148
this.getContainer().addChild(this.lumaWrapper);
149+
this.getContainer().onRender = () => this.sizeBlurs();
129150
}
130151

131152
public reconfigureAfterRestore(): void {
@@ -265,6 +286,8 @@ export abstract class Player extends Entity {
265286
this.applyFixedDimensions();
266287
}
267288

289+
this.updateClipFilter();
290+
268291
// Update wipe/reveal mask animation
269292
this.updateWipeMask();
270293
this.updateWipeFilter();
@@ -301,14 +324,43 @@ export abstract class Player extends Entity {
301324
this.wipeMask.fill(0xffffff);
302325
}
303326

327+
private updateClipFilter(): void {
328+
const { asset } = this.clipConfiguration;
329+
const rendersAsVideo = asset.type === "rich-text" && Boolean(asset.animation?.preset);
330+
const name = FILTERED_PLAYERS.has(this.playerType) && !rendersAsVideo ? this.clipConfiguration.filter : undefined;
331+
332+
if (name !== this.clipFilterName) {
333+
this.clipFilter?.destroy();
334+
this.clipFilter = this.createClipFilter(name);
335+
this.clipFilterName = name;
336+
this.syncContentFilters();
337+
}
338+
}
339+
340+
private createClipFilter(name: ResolvedClip["filter"]): pixi.Filter | null {
341+
if (name === "blur") {
342+
const blur = new pixi.BlurFilter();
343+
// The render's box blur clamps at the clip's edges, so they stay sharp.
344+
blur.repeatEdgePixels = true;
345+
return blur;
346+
}
347+
const color = name && COLOR_FILTERS[name];
348+
return color ? new ColorFilter(color) : null;
349+
}
350+
351+
private syncContentFilters(): void {
352+
// The render filters a clip before its transitions.
353+
this.contentContainer.filters = [this.clipFilter, this.wipeFilter].filter((filter): filter is pixi.Filter => filter !== null);
354+
}
355+
304356
private updateWipeFilter(): void {
305357
if (!this.wipeBrightnessBuilder) {
306358
this.removeWipeFilter();
307359
return;
308360
}
309361
if (!this.wipeFilter) {
310362
this.wipeFilter = new WipeFilter();
311-
this.contentContainer.filters = [this.wipeFilter];
363+
this.syncContentFilters();
312364
}
313365
const time = this.getPlaybackTime();
314366
this.wipeFilter.brightness = this.wipeBrightnessBuilder.getValue(time);
@@ -317,9 +369,9 @@ export abstract class Player extends Entity {
317369

318370
private removeWipeFilter(): void {
319371
if (!this.wipeFilter) return;
320-
this.contentContainer.filters = [];
321372
this.wipeFilter.destroy();
322373
this.wipeFilter = null;
374+
this.syncContentFilters();
323375
}
324376

325377
private updateZoomBlur(): void {
@@ -334,14 +386,19 @@ export abstract class Player extends Entity {
334386
this.zoomBlurFilter = new pixi.BlurFilter();
335387
this.getContainer().filters = [this.zoomBlurFilter];
336388
}
337-
// The render blurs by a whole-pixel radius before applying the clip's scale, so on screen the blur grows with scale
338-
// and the canvas zoom. pixi's Gaussian approximates the render's three box-blur passes.
339-
const time = this.getPlaybackTime();
340-
const radius = Math.trunc(this.zoomBlurBuilder.getValue(time));
341-
const canvas = this.getContainer().parent?.worldTransform;
342-
const canvasZoom = canvas ? Math.hypot(canvas.a, canvas.b) : 1;
343-
this.zoomBlurFilter.enabled = radius > 0;
344-
this.zoomBlurFilter.strength = radius * (this.scaleKeyframeBuilder?.getValue(time) ?? 1) * canvasZoom;
389+
this.zoomBlurRadius = Math.trunc(this.zoomBlurBuilder.getValue(this.getPlaybackTime()));
390+
this.zoomBlurFilter.enabled = this.zoomBlurRadius > 0;
391+
}
392+
393+
// The render blurs by whole pixels before applying the clip's scale, so a blur grows with scale and the zoom it is
394+
// drawn at. Sizing it as each frame draws follows that zoom: the editor's on screen, 1:1 in a capture.
395+
private sizeBlurs(): void {
396+
if (!this.zoomBlurFilter && !(this.clipFilter instanceof pixi.BlurFilter)) return;
397+
const canvas = this.getContainer().parent?.getGlobalTransform();
398+
const scale = (this.scaleKeyframeBuilder?.getValue(this.getPlaybackTime()) ?? 1) * (canvas ? Math.hypot(canvas.a, canvas.b) : 1);
399+
// pixi's Gaussian approximates the render's three box-blur passes.
400+
if (this.zoomBlurFilter) this.zoomBlurFilter.strength = this.zoomBlurRadius * scale;
401+
if (this.clipFilter instanceof pixi.BlurFilter) this.clipFilter.strength = CLIP_BLUR_STRENGTH * scale;
345402
}
346403

347404
public override dispose(): void {
@@ -351,6 +408,9 @@ export abstract class Player extends Entity {
351408
this.wipeFilter = null;
352409
this.zoomBlurFilter?.destroy();
353410
this.zoomBlurFilter = null;
411+
this.clipFilter?.destroy();
412+
this.clipFilter = null;
413+
this.clipFilterName = undefined;
354414

355415
this.contentContainer?.destroy();
356416
this.lumaWrapper?.destroy();

‎tests/helpers/pixi-mock-filters.ts‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,17 @@
1-
/** pixi.js filter/shader stubs so WipeFilter (extends pixi.Filter at module load) survives a mocked pixi. */
1+
/** pixi.js filter/shader stubs so the player's filters (some extend pixi.Filter at module load) survive a mocked pixi. */
22
/* eslint-disable max-classes-per-file, class-methods-use-this, import/prefer-default-export -- minimal pixi API stubs for jest mocks */
33

44
export const pixiFilterStubs = {
55
Filter: class {
66
destroy(): void {}
77
},
8+
BlurFilter: class {
9+
strength = 0;
10+
11+
repeatEdgePixels = false;
12+
13+
destroy(): void {}
14+
},
815
GlProgram: { from: (): Record<string, unknown> => ({}) },
916
GpuProgram: { from: (): Record<string, unknown> => ({}) },
1017
UniformGroup: class {

‎tests/player-filters.test.ts‎

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import { WipeFilter } from "@animations/wipe-filter";
2+
import { ColorFilter } from "@canvas/players/color-filter";
3+
import { Player, PlayerType } from "@canvas/players/player";
4+
import type { Edit } from "@core/edit-session";
5+
import { sec } from "@core/timing/types";
6+
import type { Size } from "@layouts/geometry";
7+
import type { ResolvedClip } from "@schemas";
8+
9+
jest.mock("pixi.js", () => {
10+
const point = () => ({ set: jest.fn() });
11+
return {
12+
// eslint-disable-next-line global-require, @typescript-eslint/no-require-imports -- jest.mock factories can't use imports
13+
...require("./helpers/pixi-mock-filters").pixiFilterStubs,
14+
Container: jest.fn().mockImplementation(() => ({ addChild: jest.fn(), scale: point(), pivot: point(), position: point(), skew: point() }))
15+
};
16+
});
17+
18+
class StillPlayer extends Player {
19+
public override getSize(): Size {
20+
return this.edit.size;
21+
}
22+
}
23+
24+
const draw = (playerType: PlayerType, fields: Partial<ResolvedClip>): Player => {
25+
const edit = { playbackTime: 1, size: { width: 1920, height: 1080 } };
26+
const clip = { id: "clip-1", asset: { type: "image", src: "https://example.com/a.png" }, start: sec(0), length: sec(5), ...fields };
27+
const player = new StillPlayer(edit as unknown as Edit, clip as ResolvedClip, playerType);
28+
player.reconfigureAfterRestore();
29+
player.applyPlayheadState();
30+
return player;
31+
};
32+
33+
const filtersOf = (player: Player) => player.getContentContainer().filters ?? [];
34+
35+
describe("Player clip filters", () => {
36+
it("draws an image through its filter", () => {
37+
expect(filtersOf(draw(PlayerType.Image, { filter: "negative" }))).toEqual([expect.any(ColorFilter)]);
38+
});
39+
40+
it("blurs with edges clamped like the render's box blur, sized by the clip's scale as it draws", () => {
41+
const player = draw(PlayerType.Image, { filter: "blur", scale: 0.5 });
42+
player.getContainer().onRender?.({} as never);
43+
const [blur] = filtersOf(player) as unknown as { repeatEdgePixels: boolean; strength: number }[];
44+
45+
expect(blur.repeatEdgePixels).toBe(true);
46+
expect(blur.strength).toBeCloseTo(4.69 * 0.5);
47+
});
48+
49+
it("leaves clips the render draws as video unfiltered", () => {
50+
expect(filtersOf(draw(PlayerType.Video, { filter: "greyscale" }))).toEqual([]);
51+
expect(filtersOf(draw(PlayerType.Html5, { filter: "greyscale" }))).toEqual([]);
52+
});
53+
54+
it("filters rich text only while it renders as an image", () => {
55+
const text = { type: "rich-text", text: "Hi" } as ResolvedClip["asset"];
56+
const animated = { ...text, animation: { preset: "typewriter" } } as ResolvedClip["asset"];
57+
58+
expect(filtersOf(draw(PlayerType.RichText, { asset: text, filter: "darken" }))).toEqual([expect.any(ColorFilter)]);
59+
expect(filtersOf(draw(PlayerType.RichText, { asset: animated, filter: "darken" }))).toEqual([]);
60+
});
61+
62+
it("applies the filter before a wipe transition", () => {
63+
const player = draw(PlayerType.Image, { filter: "lighten", transition: { in: "wipeRight" } });
64+
65+
expect(filtersOf(player)).toEqual([expect.any(ColorFilter), expect.any(WipeFilter)]);
66+
});
67+
68+
it("drops the filter when the clip clears it", () => {
69+
const player = draw(PlayerType.Image, { filter: "muted" });
70+
player.clipConfiguration.filter = "none";
71+
player.applyPlayheadState();
72+
73+
expect(filtersOf(player)).toEqual([]);
74+
});
75+
});

0 commit comments

Comments
 (0)