Merge branch 't3-filters' into integration

This commit is contained in:
2026-09-04 16:40:47 +08:00
3 changed files with 390 additions and 31 deletions
+157 -9
View File
@@ -1,22 +1,170 @@
<script setup lang="ts"> <script setup lang="ts">
import { onBeforeUnmount, reactive, watch } from "vue";
import { FabricImage, filters } from "fabric";
import type { FilterDef, FilterParam } from "@/types";
import { FILTERS, FILTER_TYPES } from "@/editor/filters";
import { useEditor } from "@/editor/useEditor";
/** /**
* 【T3 待实现 — 滤镜面板】 * 滤镜面板。选中图片时显示(Sidebar 已按 selectionKind === "image" 挂载)。
* *
* 选中图片时显示。读 `@/editor/filters` 的 FILTERS 注册表,按每个 FilterDef 的 * 遍历 FILTERS 注册表,为每个滤镜渲染一个开关 + 按 `params` 自动生成的滑杆。
* `params` 自动渲染滑杆;开关某个滤镜就往 `image.filters` 里增删,然后 * 开关在 `image.filters` 里增删实例;参数变化先改实例再合帧重算,
* `image.applyFilters()` + `canvas.requestRenderAll()`。 * 避免 1200×630 的图上每个 input 事件都跑一遍 `applyFilters()`。
*
* 注意 `applyFilters()` 是重活,滑杆要节流,不要每个像素移动都跑一遍。
*/ */
const { activeObject, canvas } = useEditor();
interface FilterState {
enabled: boolean;
values: Record<string, number>;
}
const state = reactive<Record<string, FilterState>>({});
for (const def of FILTERS) {
const values: Record<string, number> = {};
for (const p of def.params) values[p.key] = p.default;
state[def.id] = { enabled: false, values };
}
function currentImage(): FabricImage | null {
const obj = activeObject.value;
return obj instanceof FabricImage ? obj : null;
}
/** 从当前选中图片反推每个滤镜的启用状态与参数值。 */
function readFromImage() {
const image = currentImage();
const list = image
? (image.filters as filters.BaseFilter<string>[])
: [];
for (const def of FILTERS) {
const type = FILTER_TYPES[def.id];
const found = list.find((f) => f.type === type);
const st = state[def.id];
if (found) {
st.enabled = true;
for (const p of def.params) {
const v = (found as unknown as Record<string, unknown>)[p.key];
st.values[p.key] = typeof v === "number" ? v : p.default;
}
} else {
st.enabled = false;
for (const p of def.params) st.values[p.key] = p.default;
}
}
}
// `applyFilters()` 是重活:用 requestAnimationFrame 合帧,最多每帧跑一次。
let rafId: number | null = null;
function scheduleApply() {
if (rafId !== null) return;
rafId = requestAnimationFrame(() => {
rafId = null;
const image = currentImage();
const c = canvas.value;
if (!image || !c) return;
image.applyFilters();
c.requestRenderAll();
});
}
function toggle(def: FilterDef) {
const image = currentImage();
if (!image) return;
const type = FILTER_TYPES[def.id];
const st = state[def.id];
st.enabled = !st.enabled;
if (st.enabled) {
const instance = def.build({ ...st.values }) as filters.BaseFilter<string>;
image.filters.push(instance);
} else {
image.filters = image.filters.filter((f) => f.type !== type);
}
scheduleApply();
}
function onParam(def: FilterDef, p: FilterParam, event: Event) {
const image = currentImage();
if (!image) return;
const value = Number((event.target as HTMLInputElement).value);
state[def.id].values[p.key] = value;
const type = FILTER_TYPES[def.id];
const instance = (
image.filters as filters.BaseFilter<string>[]
).find((f) => f.type === type);
if (instance) {
(instance as unknown as Record<string, unknown>)[p.key] = value;
scheduleApply();
}
}
watch(activeObject, readFromImage, { immediate: true });
onBeforeUnmount(() => {
if (rafId !== null) cancelAnimationFrame(rafId);
rafId = null;
});
</script> </script>
<template> <template>
<p class="todo">T3 待实现:模糊 / 亮度 / 对比度 / 饱和度 / 像素化 / 晶格化</p> <div class="filter-panel">
<div v-for="def in FILTERS" :key="def.id" class="filter">
<label class="row toggle">
<input
type="checkbox"
:checked="state[def.id].enabled"
@change="toggle(def)"
/>
<span>{{ def.label }}</span>
</label>
<div v-if="state[def.id].enabled" class="params">
<div v-for="p in def.params" :key="p.key" class="field">
<div class="row param-head">
<label class="param-label">{{ p.label }}</label>
<span class="param-value">{{ state[def.id].values[p.key] }}</span>
</div>
<input
type="range"
:min="p.min"
:max="p.max"
:step="p.step"
:value="state[def.id].values[p.key]"
@input="onParam(def, p, $event)"
/>
</div>
</div>
</div>
</div>
</template> </template>
<style scoped> <style scoped>
.todo { .toggle {
color: var(--fg-dim); cursor: pointer;
color: var(--fg);
margin-bottom: 6px;
}
.toggle input {
width: auto;
}
.params {
padding: 2px 0 8px 24px;
}
.param-head {
justify-content: space-between;
}
.param-label {
margin: 0; margin: 0;
} }
.param-value {
color: var(--fg-dim);
font-variant-numeric: tabular-nums;
}
</style> </style>
+130 -13
View File
@@ -1,19 +1,136 @@
import { classRegistry, filters } from "fabric";
import type { T2DPipelineState, TWebGLUniformLocationMap } from "fabric";
const { BaseFilter } = filters;
type CrystallizeOwnProps = {
cellSize: number;
};
const crystallizeDefaultValues: CrystallizeOwnProps = {
cellSize: 24,
};
/** /**
* 【T3 待实现 — 晶格化滤镜】 * 抖动网格 Voronoi 晶格化。
* *
* 目标是视觉上接近 Photoshop 的「晶格化」:把画面切成多边形色块,每块填一个纯色。 * 把画面划成 cellSize×cellSize 的规则网格,每格的种子点在格内伪随机偏移,
* 做法用抖动网格 Voronoi——把画面划成规则网格,每格的种子点在格内随机偏移,
* 每个像素取最近的种子点,填该种子点处的颜色。视觉上和真 Voronoi 难分辨, * 每个像素取最近的种子点,填该种子点处的颜色。视觉上和真 Voronoi 难分辨,
* 但只需要查邻近 9 格,复杂度可控。 * 但只需要查邻近 9 格,复杂度可控。
*
* 实现要继承 fabric 的 `filters.BaseFilter`,模板见
* `node_modules/fabric/dist/src/filters/Boilerplate.d.ts`。
*
* **必须同时实现两条路径**(fabric 会按后端能力二选一,只写一条会在另一条上静默不生效):
* - `getFragmentSource()` + `static uniformLocations` + `sendUniformData()` → WebGL
* - `applyTo2d(options)` → Canvas2D 降级
*
* 参数至少要有格子大小(cellSize),注册进 ../index.ts 的 FILTERS。
*/ */
const fragmentSource = `
precision highp float;
uniform sampler2D uTexture;
uniform float uCellSize;
uniform float uStepW;
uniform float uStepH;
varying vec2 vTexCoord;
export {}; float hash21(vec2 p) {
p = fract(p * vec2(123.34, 345.45));
p += dot(p, p + 34.345);
return fract(p.x * p.y);
}
void main() {
vec2 pixel = vec2(vTexCoord.x / uStepW, vTexCoord.y / uStepH);
float cs = max(uCellSize, 1.0);
vec2 grid = floor(pixel / cs);
vec2 bestSeed = pixel;
float bestDist = 1e20;
for (int dy = -1; dy <= 1; dy++) {
for (int dx = -1; dx <= 1; dx++) {
vec2 g = grid + vec2(float(dx), float(dy));
vec2 offset = vec2(hash21(g), hash21(g + vec2(71.71, 17.17)));
vec2 seed = (g + offset) * cs;
vec2 diff = pixel - seed;
float d = dot(diff, diff);
if (d < bestDist) {
bestDist = d;
bestSeed = seed;
}
}
}
gl_FragColor = texture2D(uTexture, bestSeed * vec2(uStepW, uStepH));
}
`;
/** 把整数格坐标 (x, y) 散列成 [0, 1) 的伪随机数,seed 用来取不同分量。 */
function hash2(x: number, y: number, seed: number): number {
let h =
(Math.imul(x, 374761393) +
Math.imul(y, 668265263) +
Math.imul(seed, 2246822519)) |
0;
h = Math.imul(h ^ (h >>> 13), 1274126177);
return ((h ^ (h >>> 16)) >>> 0) / 4294967296;
}
export class Crystallize extends BaseFilter<"Crystallize", CrystallizeOwnProps> {
/** 运行时由 BaseFilter 构造器 `Object.assign(this, defaults, options)` 注入。 */
declare cellSize: number;
static type = "Crystallize";
static defaults = crystallizeDefaultValues;
static uniformLocations = ["uCellSize"];
protected getFragmentSource(): string {
return fragmentSource;
}
isNeutralState(): boolean {
return this.cellSize === 1;
}
applyTo2d({ imageData: { data, width, height } }: T2DPipelineState): void {
const cellSize = Math.max(1, Math.floor(this.cellSize));
if (cellSize === 1) return;
const src = new Uint8ClampedArray(data);
// 抖动网格 Voronoi。越界格也参与「取最近」,与 WebGL 路径一致;
// 采样坐标在下方 clamp 回图内,等价于纹理的 CLAMP_TO_EDGE。
const seedAt = (gx: number, gy: number) => ({
x: gx * cellSize + hash2(gx, gy, 0) * cellSize,
y: gy * cellSize + hash2(gx, gy, 1) * cellSize,
});
for (let y = 0; y < height; y++) {
const gy = Math.floor(y / cellSize);
for (let x = 0; x < width; x++) {
const gx = Math.floor(x / cellSize);
let bestDist = Infinity;
let bestSeed = seedAt(gx, gy);
for (let dy = -1; dy <= 1; dy++) {
for (let dx = -1; dx <= 1; dx++) {
const s = seedAt(gx + dx, gy + dy);
const ddx = x - s.x;
const ddy = y - s.y;
const d = ddx * ddx + ddy * ddy;
if (d < bestDist) {
bestDist = d;
bestSeed = s;
}
}
}
const sx = Math.min(width - 1, Math.max(0, Math.floor(bestSeed.x)));
const sy = Math.min(height - 1, Math.max(0, Math.floor(bestSeed.y)));
const si = (sy * width + sx) * 4;
const di = (y * width + x) * 4;
data[di] = src[si];
data[di + 1] = src[si + 1];
data[di + 2] = src[si + 2];
data[di + 3] = src[si + 3];
}
}
}
sendUniformData(
gl: WebGLRenderingContext,
uniformLocations: TWebGLUniformLocationMap,
): void {
gl.uniform1f(uniformLocations.uCellSize, this.cellSize);
}
}
classRegistry.setClass(Crystallize);
+103 -9
View File
@@ -1,13 +1,107 @@
import { filters } from "fabric";
import type { FilterDef } from "@/types"; import type { FilterDef } from "@/types";
import { Crystallize } from "./crystallize";
/** /**
* 【T3 待实现 — 滤镜注册表】 * 滤镜注册表。FilterPanel 按每个 FilterDef 的 `params` 自动渲染滑杆,
* * 不需要为每个滤镜单独写界面;加滤镜就是往这里 push 一条。
* 往这个数组里注册滤镜,FilterPanel 会按 `params` 自动渲染滑杆,
* 不需要为每个滤镜单独写界面。
*
* 第一版要有:高斯模糊、亮度、对比度、饱和度、像素化(都是 fabric 内建的
* `filters.Blur / Brightness / Contrast / Saturation / Pixelate`),
* 外加自定义的晶格化(见 ./crystallize)。
*/ */
export const FILTERS: FilterDef[] = []; export const FILTERS: FilterDef[] = [
{
id: "blur",
label: "高斯模糊",
params: [
{ key: "blur", label: "模糊", min: 0, max: 1, step: 0.01, default: 0 },
],
build: (values) => new filters.Blur({ blur: values.blur }),
},
{
id: "brightness",
label: "亮度",
params: [
{
key: "brightness",
label: "亮度",
min: -1,
max: 1,
step: 0.01,
default: 0,
},
],
build: (values) => new filters.Brightness({ brightness: values.brightness }),
},
{
id: "contrast",
label: "对比度",
params: [
{
key: "contrast",
label: "对比度",
min: -1,
max: 1,
step: 0.01,
default: 0,
},
],
build: (values) => new filters.Contrast({ contrast: values.contrast }),
},
{
id: "saturation",
label: "饱和度",
params: [
{
key: "saturation",
label: "饱和度",
min: -1,
max: 1,
step: 0.01,
default: 0,
},
],
build: (values) => new filters.Saturation({ saturation: values.saturation }),
},
{
id: "pixelate",
label: "像素化",
params: [
{
key: "blocksize",
label: "块大小",
min: 1,
max: 64,
step: 1,
default: 4,
},
],
build: (values) => new filters.Pixelate({ blocksize: values.blocksize }),
},
{
id: "crystallize",
label: "晶格化",
params: [
{
key: "cellSize",
label: "格子大小",
min: 1,
max: 128,
step: 1,
default: 24,
},
],
build: (values) => new Crystallize({ cellSize: values.cellSize }),
},
];
/**
* 每个滤镜的 fabric 真实 `type`,从 `build()` 造出的实例上读取,
* 不靠 `id` 的首字母大写拼写猜测——id 与 fabric type 是两套独立命名。
*/
function typeOf(def: FilterDef): string {
const values: Record<string, number> = {};
for (const p of def.params) values[p.key] = p.default;
return (def.build(values) as filters.BaseFilter<string>).type;
}
export const FILTER_TYPES: Readonly<Record<string, string>> = Object.fromEntries(
FILTERS.map((def) => [def.id, typeOf(def)]),
);