Add noise gate, palette quantization filters and drag-and-drop filter ordering
- Add noise gate filter: suppresses per-pixel color flicker below threshold using stateful frame comparison with pre-allocated int16 buffers - Add palette quantization filter: maps pixels to nearest color in preset or custom hex palette, using chunked processing for memory efficiency - Add "string" option type to filter schema system (base, API, frontend) - Replace up/down buttons with pointer-event drag-and-drop in PP template filter list, with clone/placeholder feedback and modal auto-scroll - Add frame_interpolation locale keys (was missing from all 3 locales) - Update TODO.md: mark completed processing pipeline items Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
8
TODO.md
8
TODO.md
@@ -4,11 +4,11 @@ Priority: `P1` quick win · `P2` moderate · `P3` large effort
|
|||||||
|
|
||||||
## Processing Pipeline
|
## Processing Pipeline
|
||||||
|
|
||||||
- [ ] `P1` **Noise gate** — Suppress small color changes below threshold, preventing shimmer on static content
|
- [x] `P1` **Noise gate** — Suppress small color changes below threshold, preventing shimmer on static content
|
||||||
- [ ] `P1` **Color temperature filter** — Warm/cool shift separate from hue shift (circadian/mood)
|
- [x] `P1` **Color temperature filter** — Already covered by existing Color Correction filter (2000-10000K)
|
||||||
- [ ] `P1` **Zone grouping** — Merge adjacent LEDs into logical groups sharing one averaged color
|
- [ ] `P1` **Zone grouping** — Merge adjacent LEDs into logical groups sharing one averaged color
|
||||||
- [ ] `P2` **Palette quantization** — Force output to match a user-defined palette
|
- [x] `P2` **Palette quantization** — Force output to match a user-defined palette (preset or custom hex)
|
||||||
- [ ] `P2` **Drag-and-drop filter ordering** — Reorder postprocessing filter chains visually
|
- [x] `P2` **Drag-and-drop filter ordering** — Reorder postprocessing filter chains visually
|
||||||
- [ ] `P3` **Transition effects** — Crossfade, wipe, or dissolve between sources/profiles instead of instant cut
|
- [ ] `P3` **Transition effects** — Crossfade, wipe, or dissolve between sources/profiles instead of instant cut
|
||||||
|
|
||||||
## Output Targets
|
## Output Targets
|
||||||
|
|||||||
@@ -17,12 +17,13 @@ class FilterOptionDefSchema(BaseModel):
|
|||||||
|
|
||||||
key: str = Field(description="Option key")
|
key: str = Field(description="Option key")
|
||||||
label: str = Field(description="Display label")
|
label: str = Field(description="Display label")
|
||||||
type: str = Field(description="Option type (float, int, bool, or select)")
|
type: str = Field(description="Option type (float, int, bool, select, or string)")
|
||||||
default: Any = Field(description="Default value")
|
default: Any = Field(description="Default value")
|
||||||
min_value: Any = Field(description="Minimum value")
|
min_value: Any = Field(description="Minimum value")
|
||||||
max_value: Any = Field(description="Maximum value")
|
max_value: Any = Field(description="Maximum value")
|
||||||
step: Any = Field(description="Step increment")
|
step: Any = Field(description="Step increment")
|
||||||
choices: Optional[List[Dict[str, str]]] = Field(default=None, description="Available choices for select type")
|
choices: Optional[List[Dict[str, str]]] = Field(default=None, description="Available choices for select type")
|
||||||
|
max_length: Optional[int] = Field(default=None, description="Maximum string length for string type")
|
||||||
|
|
||||||
|
|
||||||
class FilterTypeResponse(BaseModel):
|
class FilterTypeResponse(BaseModel):
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ import wled_controller.core.filters.flip # noqa: F401
|
|||||||
import wled_controller.core.filters.color_correction # noqa: F401
|
import wled_controller.core.filters.color_correction # noqa: F401
|
||||||
import wled_controller.core.filters.frame_interpolation # noqa: F401
|
import wled_controller.core.filters.frame_interpolation # noqa: F401
|
||||||
import wled_controller.core.filters.filter_template # noqa: F401
|
import wled_controller.core.filters.filter_template # noqa: F401
|
||||||
|
import wled_controller.core.filters.noise_gate # noqa: F401
|
||||||
|
import wled_controller.core.filters.palette_quantization # noqa: F401
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"FilterOptionDef",
|
"FilterOptionDef",
|
||||||
|
|||||||
@@ -13,12 +13,13 @@ class FilterOptionDef:
|
|||||||
|
|
||||||
key: str
|
key: str
|
||||||
label: str
|
label: str
|
||||||
option_type: str # "float" | "int" | "bool" | "select"
|
option_type: str # "float" | "int" | "bool" | "select" | "string"
|
||||||
default: Any
|
default: Any
|
||||||
min_value: Any
|
min_value: Any
|
||||||
max_value: Any
|
max_value: Any
|
||||||
step: Any
|
step: Any
|
||||||
choices: Optional[List[Dict[str, str]]] = None # for "select": [{value, label}]
|
choices: Optional[List[Dict[str, str]]] = None # for "select": [{value, label}]
|
||||||
|
max_length: Optional[int] = None # for "string" type
|
||||||
|
|
||||||
def to_dict(self) -> dict:
|
def to_dict(self) -> dict:
|
||||||
d = {
|
d = {
|
||||||
@@ -32,6 +33,8 @@ class FilterOptionDef:
|
|||||||
}
|
}
|
||||||
if self.choices is not None:
|
if self.choices is not None:
|
||||||
d["choices"] = self.choices
|
d["choices"] = self.choices
|
||||||
|
if self.max_length is not None:
|
||||||
|
d["max_length"] = self.max_length
|
||||||
return d
|
return d
|
||||||
|
|
||||||
|
|
||||||
@@ -86,10 +89,12 @@ class PostprocessingFilter(ABC):
|
|||||||
val = bool(raw) if not isinstance(raw, bool) else raw
|
val = bool(raw) if not isinstance(raw, bool) else raw
|
||||||
elif opt_def.option_type == "select":
|
elif opt_def.option_type == "select":
|
||||||
val = str(raw) if raw is not None else opt_def.default
|
val = str(raw) if raw is not None else opt_def.default
|
||||||
|
elif opt_def.option_type == "string":
|
||||||
|
val = str(raw) if raw is not None else opt_def.default
|
||||||
else:
|
else:
|
||||||
val = raw
|
val = raw
|
||||||
# Clamp to range (skip for bools and selects)
|
# Clamp to range (skip for non-numeric types)
|
||||||
if opt_def.option_type not in ("bool", "select"):
|
if opt_def.option_type not in ("bool", "select", "string"):
|
||||||
if opt_def.min_value is not None and val < opt_def.min_value:
|
if opt_def.min_value is not None and val < opt_def.min_value:
|
||||||
val = opt_def.min_value
|
val = opt_def.min_value
|
||||||
if opt_def.max_value is not None and val > opt_def.max_value:
|
if opt_def.max_value is not None and val > opt_def.max_value:
|
||||||
|
|||||||
82
server/src/wled_controller/core/filters/noise_gate.py
Normal file
82
server/src/wled_controller/core/filters/noise_gate.py
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
"""Noise gate postprocessing filter."""
|
||||||
|
|
||||||
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
from wled_controller.core.filters.base import FilterOptionDef, PostprocessingFilter
|
||||||
|
from wled_controller.core.filters.image_pool import ImagePool
|
||||||
|
from wled_controller.core.filters.registry import FilterRegistry
|
||||||
|
|
||||||
|
|
||||||
|
@FilterRegistry.register
|
||||||
|
class NoiseGateFilter(PostprocessingFilter):
|
||||||
|
"""Suppresses small per-pixel color changes below threshold.
|
||||||
|
|
||||||
|
Prevents shimmer / flicker on static or near-static content by keeping
|
||||||
|
the previous pixel value when the max per-channel delta is below the
|
||||||
|
configured threshold.
|
||||||
|
"""
|
||||||
|
|
||||||
|
filter_id = "noise_gate"
|
||||||
|
filter_name = "Noise Gate"
|
||||||
|
|
||||||
|
def __init__(self, options: Dict[str, Any]):
|
||||||
|
super().__init__(options)
|
||||||
|
self._prev_frame: Optional[np.ndarray] = None
|
||||||
|
# Pre-allocated scratch buffers (avoid per-frame allocation)
|
||||||
|
self._i16_cur: Optional[np.ndarray] = None
|
||||||
|
self._i16_prev: Optional[np.ndarray] = None
|
||||||
|
self._buf_shape: Optional[tuple] = None
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_options_schema(cls) -> List[FilterOptionDef]:
|
||||||
|
return [
|
||||||
|
FilterOptionDef(
|
||||||
|
key="threshold",
|
||||||
|
label="Threshold",
|
||||||
|
option_type="int",
|
||||||
|
default=10,
|
||||||
|
min_value=1,
|
||||||
|
max_value=50,
|
||||||
|
step=1,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
def process_image(self, image: np.ndarray, image_pool: ImagePool) -> Optional[np.ndarray]:
|
||||||
|
threshold = self.options["threshold"]
|
||||||
|
h, w, c = image.shape
|
||||||
|
shape = (h, w, c)
|
||||||
|
|
||||||
|
if self._prev_frame is None or self._prev_frame.shape != shape:
|
||||||
|
self._prev_frame = image.copy()
|
||||||
|
self._i16_cur = np.empty(shape, dtype=np.int16)
|
||||||
|
self._i16_prev = np.empty(shape, dtype=np.int16)
|
||||||
|
self._buf_shape = shape
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Reallocate scratch if shape changed
|
||||||
|
if self._buf_shape != shape:
|
||||||
|
self._i16_cur = np.empty(shape, dtype=np.int16)
|
||||||
|
self._i16_prev = np.empty(shape, dtype=np.int16)
|
||||||
|
self._buf_shape = shape
|
||||||
|
|
||||||
|
# Compute per-channel absolute delta using int16 to avoid uint8 underflow
|
||||||
|
np.copyto(self._i16_cur, image, casting="unsafe")
|
||||||
|
np.copyto(self._i16_prev, self._prev_frame, casting="unsafe")
|
||||||
|
self._i16_cur -= self._i16_prev
|
||||||
|
np.abs(self._i16_cur, out=self._i16_cur)
|
||||||
|
|
||||||
|
# Per-pixel: max channel delta
|
||||||
|
max_delta = self._i16_cur.max(axis=2) # shape (h, w)
|
||||||
|
|
||||||
|
# Boolean mask: True where pixel is stable (keep previous)
|
||||||
|
mask = max_delta < threshold
|
||||||
|
mask_3d = mask[:, :, np.newaxis] # broadcast to (h, w, 1)
|
||||||
|
|
||||||
|
# Apply: keep previous where stable, otherwise take new
|
||||||
|
np.where(mask_3d, self._prev_frame, image, out=image)
|
||||||
|
|
||||||
|
# Update stored frame
|
||||||
|
np.copyto(self._prev_frame, image)
|
||||||
|
return None
|
||||||
118
server/src/wled_controller/core/filters/palette_quantization.py
Normal file
118
server/src/wled_controller/core/filters/palette_quantization.py
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
"""Palette quantization postprocessing filter."""
|
||||||
|
|
||||||
|
import re
|
||||||
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
from wled_controller.core.filters.base import FilterOptionDef, PostprocessingFilter
|
||||||
|
from wled_controller.core.filters.image_pool import ImagePool
|
||||||
|
from wled_controller.core.filters.registry import FilterRegistry
|
||||||
|
|
||||||
|
_PRESETS = {
|
||||||
|
"warm": "#FF4500,#FF6347,#FF8C00,#FFD700,#FFA500,#DC143C",
|
||||||
|
"cool": "#0000FF,#1E90FF,#00BFFF,#00CED1,#4682B4,#6A5ACD",
|
||||||
|
"neon": "#FF00FF,#00FFFF,#00FF00,#FFFF00,#FF0000,#0000FF",
|
||||||
|
"pastel": "#FFB3BA,#FFDFBA,#FFFFBA,#BAFFC9,#BAE1FF,#E8BAFF",
|
||||||
|
"monochrome": "#000000,#404040,#808080,#C0C0C0,#FFFFFF",
|
||||||
|
"rgb": "#FF0000,#00FF00,#0000FF",
|
||||||
|
"rainbow": "#FF0000,#FF7F00,#FFFF00,#00FF00,#0000FF,#4B0082,#8F00FF",
|
||||||
|
"earth": "#8B4513,#A0522D,#D2691E,#228B22,#006400,#2F4F4F",
|
||||||
|
}
|
||||||
|
|
||||||
|
_HEX_RE = re.compile(r"^#?([0-9a-fA-F]{6})$")
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_hex_colors(color_str: str) -> Optional[np.ndarray]:
|
||||||
|
"""Parse comma-separated hex colors into (N, 3) uint8 array."""
|
||||||
|
colors = []
|
||||||
|
for part in color_str.split(","):
|
||||||
|
part = part.strip()
|
||||||
|
m = _HEX_RE.match(part)
|
||||||
|
if m:
|
||||||
|
h = m.group(1)
|
||||||
|
colors.append((int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16)))
|
||||||
|
if not colors:
|
||||||
|
return None
|
||||||
|
return np.array(colors, dtype=np.uint8)
|
||||||
|
|
||||||
|
|
||||||
|
@FilterRegistry.register
|
||||||
|
class PaletteQuantizationFilter(PostprocessingFilter):
|
||||||
|
"""Maps every pixel to the nearest color in a palette."""
|
||||||
|
|
||||||
|
filter_id = "palette_quantization"
|
||||||
|
filter_name = "Palette Quantization"
|
||||||
|
|
||||||
|
def __init__(self, options: Dict[str, Any]):
|
||||||
|
super().__init__(options)
|
||||||
|
self._palette: Optional[np.ndarray] = None
|
||||||
|
self._rebuild_palette()
|
||||||
|
|
||||||
|
def _rebuild_palette(self):
|
||||||
|
mode = self.options.get("mode", "preset")
|
||||||
|
if mode == "custom":
|
||||||
|
color_str = self.options.get("colors", "")
|
||||||
|
self._palette = _parse_hex_colors(color_str)
|
||||||
|
else:
|
||||||
|
preset_name = self.options.get("preset", "rainbow")
|
||||||
|
preset_str = _PRESETS.get(preset_name, _PRESETS["rainbow"])
|
||||||
|
self._palette = _parse_hex_colors(preset_str)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_options_schema(cls) -> List[FilterOptionDef]:
|
||||||
|
return [
|
||||||
|
FilterOptionDef(
|
||||||
|
key="mode",
|
||||||
|
label="Mode",
|
||||||
|
option_type="select",
|
||||||
|
default="preset",
|
||||||
|
min_value=None,
|
||||||
|
max_value=None,
|
||||||
|
step=None,
|
||||||
|
choices=[
|
||||||
|
{"value": "preset", "label": "Preset"},
|
||||||
|
{"value": "custom", "label": "Custom"},
|
||||||
|
],
|
||||||
|
),
|
||||||
|
FilterOptionDef(
|
||||||
|
key="preset",
|
||||||
|
label="Preset Palette",
|
||||||
|
option_type="select",
|
||||||
|
default="rainbow",
|
||||||
|
min_value=None,
|
||||||
|
max_value=None,
|
||||||
|
step=None,
|
||||||
|
choices=[{"value": k, "label": k.capitalize()} for k in _PRESETS],
|
||||||
|
),
|
||||||
|
FilterOptionDef(
|
||||||
|
key="colors",
|
||||||
|
label="Custom Colors (hex, comma-separated)",
|
||||||
|
option_type="string",
|
||||||
|
default="#FF0000,#00FF00,#0000FF",
|
||||||
|
min_value=None,
|
||||||
|
max_value=None,
|
||||||
|
step=None,
|
||||||
|
max_length=500,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
def process_image(self, image: np.ndarray, image_pool: ImagePool) -> Optional[np.ndarray]:
|
||||||
|
if self._palette is None or len(self._palette) == 0:
|
||||||
|
return None
|
||||||
|
|
||||||
|
palette = self._palette.astype(np.int16)
|
||||||
|
flat = image.reshape(-1, 3)
|
||||||
|
total = flat.shape[0]
|
||||||
|
chunk_size = 32768
|
||||||
|
|
||||||
|
for start in range(0, total, chunk_size):
|
||||||
|
end = min(start + chunk_size, total)
|
||||||
|
chunk = flat[start:end].astype(np.int16)
|
||||||
|
# Squared Euclidean distance: (C, 1, 3) - (1, N, 3) -> (C, N, 3) -> sum -> (C, N)
|
||||||
|
diff = chunk[:, np.newaxis, :] - palette[np.newaxis, :, :]
|
||||||
|
dist_sq = (diff * diff).sum(axis=2)
|
||||||
|
nearest = dist_sq.argmin(axis=1)
|
||||||
|
flat[start:end] = self._palette[nearest]
|
||||||
|
|
||||||
|
return None
|
||||||
@@ -345,6 +345,17 @@
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.pp-filter-text-input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--card-bg);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 12px;
|
||||||
|
font-family: monospace;
|
||||||
|
}
|
||||||
|
|
||||||
.pp-filter-option-bool label {
|
.pp-filter-option-bool label {
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
@@ -392,6 +403,61 @@
|
|||||||
order: 0;
|
order: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── PP filter drag-and-drop ── */
|
||||||
|
|
||||||
|
.pp-filter-drag-handle {
|
||||||
|
cursor: grab;
|
||||||
|
opacity: 0;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1;
|
||||||
|
padding: 2px 4px;
|
||||||
|
border-radius: 3px;
|
||||||
|
transition: opacity 0.2s ease;
|
||||||
|
user-select: none;
|
||||||
|
touch-action: none;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pp-filter-card:hover .pp-filter-drag-handle {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pp-filter-drag-handle:hover {
|
||||||
|
opacity: 1 !important;
|
||||||
|
background: var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pp-filter-drag-handle:active {
|
||||||
|
cursor: grabbing;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pp-filter-drag-clone {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 9999;
|
||||||
|
pointer-events: none;
|
||||||
|
opacity: 0.92;
|
||||||
|
transform: scale(1.02);
|
||||||
|
box-shadow: 0 12px 36px rgba(0, 0, 0, 0.25);
|
||||||
|
will-change: top;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pp-filter-drag-placeholder {
|
||||||
|
border: 2px dashed var(--primary-color);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: rgba(33, 150, 243, 0.04);
|
||||||
|
min-height: 42px;
|
||||||
|
transition: height 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.pp-filter-dragging .pp-filter-card {
|
||||||
|
transition: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.pp-filter-dragging .pp-filter-drag-handle {
|
||||||
|
opacity: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
.pp-add-filter-row {
|
.pp-add-filter-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
|
|||||||
@@ -1942,12 +1942,11 @@ export function renderModalFilterList() {
|
|||||||
|
|
||||||
html += `<div class="pp-filter-card${isExpanded ? ' expanded' : ''}" data-filter-index="${index}">
|
html += `<div class="pp-filter-card${isExpanded ? ' expanded' : ''}" data-filter-index="${index}">
|
||||||
<div class="pp-filter-card-header" onclick="toggleFilterExpand(${index})">
|
<div class="pp-filter-card-header" onclick="toggleFilterExpand(${index})">
|
||||||
|
<span class="pp-filter-drag-handle" title="${t('filters.drag_to_reorder')}">⠇</span>
|
||||||
<span class="pp-filter-card-chevron">${isExpanded ? '▼' : '▶'}</span>
|
<span class="pp-filter-card-chevron">${isExpanded ? '▼' : '▶'}</span>
|
||||||
<span class="pp-filter-card-name">${escapeHtml(filterName)}</span>
|
<span class="pp-filter-card-name">${escapeHtml(filterName)}</span>
|
||||||
${summary ? `<span class="pp-filter-card-summary">${escapeHtml(summary)}</span>` : ''}
|
${summary ? `<span class="pp-filter-card-summary">${escapeHtml(summary)}</span>` : ''}
|
||||||
<div class="pp-filter-card-actions" onclick="event.stopPropagation()">
|
<div class="pp-filter-card-actions" onclick="event.stopPropagation()">
|
||||||
<button type="button" class="btn-filter-action" onclick="moveFilter(${index}, -1)" title="${t('filters.move_up')}" ${index === 0 ? 'disabled' : ''}>▲</button>
|
|
||||||
<button type="button" class="btn-filter-action" onclick="moveFilter(${index}, 1)" title="${t('filters.move_down')}" ${index === _modalFilters.length - 1 ? 'disabled' : ''}>▼</button>
|
|
||||||
<button type="button" class="btn-filter-action btn-filter-remove" onclick="removeFilter(${index})" title="${t('filters.remove')}">✕</button>
|
<button type="button" class="btn-filter-action btn-filter-remove" onclick="removeFilter(${index})" title="${t('filters.remove')}">✕</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1988,6 +1987,14 @@ export function renderModalFilterList() {
|
|||||||
${options}
|
${options}
|
||||||
</select>
|
</select>
|
||||||
</div>`;
|
</div>`;
|
||||||
|
} else if (opt.type === 'string') {
|
||||||
|
const maxLen = opt.max_length || 500;
|
||||||
|
html += `<div class="pp-filter-option">
|
||||||
|
<label for="${inputId}"><span>${escapeHtml(opt.label)}:</span></label>
|
||||||
|
<input type="text" id="${inputId}" value="${escapeHtml(String(currentVal))}"
|
||||||
|
maxlength="${maxLen}" class="pp-filter-text-input"
|
||||||
|
onchange="updateFilterOption(${index}, '${opt.key}', this.value)">
|
||||||
|
</div>`;
|
||||||
} else {
|
} else {
|
||||||
html += `<div class="pp-filter-option">
|
html += `<div class="pp-filter-option">
|
||||||
<label for="${inputId}">
|
<label for="${inputId}">
|
||||||
@@ -2006,6 +2013,161 @@ export function renderModalFilterList() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
container.innerHTML = html;
|
container.innerHTML = html;
|
||||||
|
_initFilterDrag();
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── PP filter drag-and-drop reordering ── */
|
||||||
|
|
||||||
|
const _FILTER_DRAG_THRESHOLD = 5;
|
||||||
|
const _FILTER_SCROLL_EDGE = 60;
|
||||||
|
const _FILTER_SCROLL_SPEED = 12;
|
||||||
|
let _filterDragState = null;
|
||||||
|
|
||||||
|
function _initFilterDrag() {
|
||||||
|
const container = document.getElementById('pp-filter-list');
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
container.addEventListener('pointerdown', (e) => {
|
||||||
|
const handle = e.target.closest('.pp-filter-drag-handle');
|
||||||
|
if (!handle) return;
|
||||||
|
const card = handle.closest('.pp-filter-card');
|
||||||
|
if (!card) return;
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
|
||||||
|
const fromIndex = parseInt(card.dataset.filterIndex, 10);
|
||||||
|
_filterDragState = {
|
||||||
|
card,
|
||||||
|
container,
|
||||||
|
startY: e.clientY,
|
||||||
|
started: false,
|
||||||
|
clone: null,
|
||||||
|
placeholder: null,
|
||||||
|
offsetY: 0,
|
||||||
|
fromIndex,
|
||||||
|
scrollRaf: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const onMove = (ev) => _onFilterDragMove(ev);
|
||||||
|
const onUp = () => {
|
||||||
|
document.removeEventListener('pointermove', onMove);
|
||||||
|
document.removeEventListener('pointerup', onUp);
|
||||||
|
_onFilterDragEnd();
|
||||||
|
};
|
||||||
|
document.addEventListener('pointermove', onMove);
|
||||||
|
document.addEventListener('pointerup', onUp);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function _onFilterDragMove(e) {
|
||||||
|
const ds = _filterDragState;
|
||||||
|
if (!ds) return;
|
||||||
|
|
||||||
|
if (!ds.started) {
|
||||||
|
if (Math.abs(e.clientY - ds.startY) < _FILTER_DRAG_THRESHOLD) return;
|
||||||
|
_startFilterDrag(ds, e);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Position clone at pointer
|
||||||
|
ds.clone.style.top = (e.clientY - ds.offsetY) + 'px';
|
||||||
|
|
||||||
|
// Find drop target by vertical midpoint
|
||||||
|
const cards = ds.container.querySelectorAll('.pp-filter-card');
|
||||||
|
for (const card of cards) {
|
||||||
|
if (card.style.display === 'none') continue;
|
||||||
|
const r = card.getBoundingClientRect();
|
||||||
|
if (e.clientY >= r.top && e.clientY <= r.bottom) {
|
||||||
|
const before = e.clientY < r.top + r.height / 2;
|
||||||
|
if (card === ds.lastTarget && before === ds.lastBefore) break;
|
||||||
|
ds.lastTarget = card;
|
||||||
|
ds.lastBefore = before;
|
||||||
|
if (before) {
|
||||||
|
ds.container.insertBefore(ds.placeholder, card);
|
||||||
|
} else {
|
||||||
|
ds.container.insertBefore(ds.placeholder, card.nextSibling);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-scroll near viewport edges
|
||||||
|
_filterAutoScroll(e.clientY, ds);
|
||||||
|
}
|
||||||
|
|
||||||
|
function _startFilterDrag(ds, e) {
|
||||||
|
ds.started = true;
|
||||||
|
const rect = ds.card.getBoundingClientRect();
|
||||||
|
|
||||||
|
// Clone for visual feedback
|
||||||
|
const clone = ds.card.cloneNode(true);
|
||||||
|
clone.className = ds.card.className + ' pp-filter-drag-clone';
|
||||||
|
clone.style.width = rect.width + 'px';
|
||||||
|
clone.style.left = rect.left + 'px';
|
||||||
|
clone.style.top = rect.top + 'px';
|
||||||
|
document.body.appendChild(clone);
|
||||||
|
ds.clone = clone;
|
||||||
|
ds.offsetY = e.clientY - rect.top;
|
||||||
|
|
||||||
|
// Placeholder
|
||||||
|
const placeholder = document.createElement('div');
|
||||||
|
placeholder.className = 'pp-filter-drag-placeholder';
|
||||||
|
placeholder.style.height = rect.height + 'px';
|
||||||
|
ds.card.parentNode.insertBefore(placeholder, ds.card);
|
||||||
|
ds.placeholder = placeholder;
|
||||||
|
|
||||||
|
// Hide original
|
||||||
|
ds.card.style.display = 'none';
|
||||||
|
document.body.classList.add('pp-filter-dragging');
|
||||||
|
}
|
||||||
|
|
||||||
|
function _onFilterDragEnd() {
|
||||||
|
const ds = _filterDragState;
|
||||||
|
_filterDragState = null;
|
||||||
|
if (!ds || !ds.started) return;
|
||||||
|
|
||||||
|
if (ds.scrollRaf) cancelAnimationFrame(ds.scrollRaf);
|
||||||
|
|
||||||
|
// Determine new index from placeholder position among children
|
||||||
|
let toIndex = 0;
|
||||||
|
for (const child of ds.container.children) {
|
||||||
|
if (child === ds.placeholder) break;
|
||||||
|
if (child.classList.contains('pp-filter-card') && child.style.display !== 'none') {
|
||||||
|
toIndex++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cleanup DOM
|
||||||
|
ds.card.style.display = '';
|
||||||
|
ds.placeholder.remove();
|
||||||
|
ds.clone.remove();
|
||||||
|
document.body.classList.remove('pp-filter-dragging');
|
||||||
|
|
||||||
|
// Reorder _modalFilters array
|
||||||
|
if (toIndex !== ds.fromIndex) {
|
||||||
|
const [item] = _modalFilters.splice(ds.fromIndex, 1);
|
||||||
|
_modalFilters.splice(toIndex, 0, item);
|
||||||
|
renderModalFilterList();
|
||||||
|
_autoGeneratePPTemplateName();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function _filterAutoScroll(clientY, ds) {
|
||||||
|
if (ds.scrollRaf) cancelAnimationFrame(ds.scrollRaf);
|
||||||
|
const modal = ds.container.closest('.modal-body');
|
||||||
|
if (!modal) return;
|
||||||
|
const rect = modal.getBoundingClientRect();
|
||||||
|
let speed = 0;
|
||||||
|
if (clientY < rect.top + _FILTER_SCROLL_EDGE) {
|
||||||
|
speed = -_FILTER_SCROLL_SPEED;
|
||||||
|
} else if (clientY > rect.bottom - _FILTER_SCROLL_EDGE) {
|
||||||
|
speed = _FILTER_SCROLL_SPEED;
|
||||||
|
}
|
||||||
|
if (speed === 0) return;
|
||||||
|
const scroll = () => {
|
||||||
|
modal.scrollTop += speed;
|
||||||
|
ds.scrollRaf = requestAnimationFrame(scroll);
|
||||||
|
};
|
||||||
|
ds.scrollRaf = requestAnimationFrame(scroll);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function addFilterFromSelect() {
|
export function addFilterFromSelect() {
|
||||||
@@ -2065,6 +2227,8 @@ export function updateFilterOption(filterIndex, optionKey, value) {
|
|||||||
fi.options[optionKey] = !!value;
|
fi.options[optionKey] = !!value;
|
||||||
} else if (optDef && optDef.type === 'select') {
|
} else if (optDef && optDef.type === 'select') {
|
||||||
fi.options[optionKey] = String(value);
|
fi.options[optionKey] = String(value);
|
||||||
|
} else if (optDef && optDef.type === 'string') {
|
||||||
|
fi.options[optionKey] = String(value);
|
||||||
} else if (optDef && optDef.type === 'int') {
|
} else if (optDef && optDef.type === 'int') {
|
||||||
fi.options[optionKey] = parseInt(value);
|
fi.options[optionKey] = parseInt(value);
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -348,8 +348,7 @@
|
|||||||
"filters.select_type": "Select filter type...",
|
"filters.select_type": "Select filter type...",
|
||||||
"filters.add": "Add Filter",
|
"filters.add": "Add Filter",
|
||||||
"filters.remove": "Remove",
|
"filters.remove": "Remove",
|
||||||
"filters.move_up": "Move Up",
|
"filters.drag_to_reorder": "Drag to reorder",
|
||||||
"filters.move_down": "Move Down",
|
|
||||||
"filters.empty": "No filters added. Use the selector below to add filters.",
|
"filters.empty": "No filters added. Use the selector below to add filters.",
|
||||||
"filters.brightness": "Brightness",
|
"filters.brightness": "Brightness",
|
||||||
"filters.saturation": "Saturation",
|
"filters.saturation": "Saturation",
|
||||||
@@ -360,6 +359,9 @@
|
|||||||
"filters.flip": "Flip",
|
"filters.flip": "Flip",
|
||||||
"filters.color_correction": "Color Correction",
|
"filters.color_correction": "Color Correction",
|
||||||
"filters.filter_template": "Filter Template",
|
"filters.filter_template": "Filter Template",
|
||||||
|
"filters.frame_interpolation": "Frame Interpolation",
|
||||||
|
"filters.noise_gate": "Noise Gate",
|
||||||
|
"filters.palette_quantization": "Palette Quantization",
|
||||||
"postprocessing.description_label": "Description (optional):",
|
"postprocessing.description_label": "Description (optional):",
|
||||||
"postprocessing.description_placeholder": "Describe this template...",
|
"postprocessing.description_placeholder": "Describe this template...",
|
||||||
"postprocessing.created": "Template created successfully",
|
"postprocessing.created": "Template created successfully",
|
||||||
|
|||||||
@@ -348,8 +348,7 @@
|
|||||||
"filters.select_type": "Выберите тип фильтра...",
|
"filters.select_type": "Выберите тип фильтра...",
|
||||||
"filters.add": "Добавить фильтр",
|
"filters.add": "Добавить фильтр",
|
||||||
"filters.remove": "Удалить",
|
"filters.remove": "Удалить",
|
||||||
"filters.move_up": "Вверх",
|
"filters.drag_to_reorder": "Перетащите для изменения порядка",
|
||||||
"filters.move_down": "Вниз",
|
|
||||||
"filters.empty": "Фильтры не добавлены. Используйте селектор ниже для добавления.",
|
"filters.empty": "Фильтры не добавлены. Используйте селектор ниже для добавления.",
|
||||||
"filters.brightness": "Яркость",
|
"filters.brightness": "Яркость",
|
||||||
"filters.saturation": "Насыщенность",
|
"filters.saturation": "Насыщенность",
|
||||||
@@ -360,6 +359,9 @@
|
|||||||
"filters.flip": "Отражение",
|
"filters.flip": "Отражение",
|
||||||
"filters.color_correction": "Цветокоррекция",
|
"filters.color_correction": "Цветокоррекция",
|
||||||
"filters.filter_template": "Шаблон фильтров",
|
"filters.filter_template": "Шаблон фильтров",
|
||||||
|
"filters.frame_interpolation": "Интерполяция кадров",
|
||||||
|
"filters.noise_gate": "Шумоподавление",
|
||||||
|
"filters.palette_quantization": "Квантизация палитры",
|
||||||
"postprocessing.description_label": "Описание (необязательно):",
|
"postprocessing.description_label": "Описание (необязательно):",
|
||||||
"postprocessing.description_placeholder": "Опишите этот шаблон...",
|
"postprocessing.description_placeholder": "Опишите этот шаблон...",
|
||||||
"postprocessing.created": "Шаблон успешно создан",
|
"postprocessing.created": "Шаблон успешно создан",
|
||||||
|
|||||||
@@ -348,8 +348,7 @@
|
|||||||
"filters.select_type": "选择滤镜类型...",
|
"filters.select_type": "选择滤镜类型...",
|
||||||
"filters.add": "添加滤镜",
|
"filters.add": "添加滤镜",
|
||||||
"filters.remove": "移除",
|
"filters.remove": "移除",
|
||||||
"filters.move_up": "上移",
|
"filters.drag_to_reorder": "拖动以重新排序",
|
||||||
"filters.move_down": "下移",
|
|
||||||
"filters.empty": "尚未添加滤镜。使用下方选择器添加滤镜。",
|
"filters.empty": "尚未添加滤镜。使用下方选择器添加滤镜。",
|
||||||
"filters.brightness": "亮度",
|
"filters.brightness": "亮度",
|
||||||
"filters.saturation": "饱和度",
|
"filters.saturation": "饱和度",
|
||||||
@@ -360,6 +359,9 @@
|
|||||||
"filters.flip": "翻转",
|
"filters.flip": "翻转",
|
||||||
"filters.color_correction": "色彩校正",
|
"filters.color_correction": "色彩校正",
|
||||||
"filters.filter_template": "滤镜模板",
|
"filters.filter_template": "滤镜模板",
|
||||||
|
"filters.frame_interpolation": "帧插值",
|
||||||
|
"filters.noise_gate": "噪声门",
|
||||||
|
"filters.palette_quantization": "调色板量化",
|
||||||
"postprocessing.description_label": "描述(可选):",
|
"postprocessing.description_label": "描述(可选):",
|
||||||
"postprocessing.description_placeholder": "描述此模板...",
|
"postprocessing.description_placeholder": "描述此模板...",
|
||||||
"postprocessing.created": "模板创建成功",
|
"postprocessing.created": "模板创建成功",
|
||||||
|
|||||||
Reference in New Issue
Block a user