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:
2026-03-01 10:58:02 +03:00
parent 62b3d44e63
commit bf2fd5ca69
11 changed files with 460 additions and 16 deletions

View File

@@ -345,6 +345,17 @@
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 {
justify-content: space-between;
gap: 8px;
@@ -392,6 +403,61 @@
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 {
display: flex;
gap: 8px;

View File

@@ -1942,12 +1942,11 @@ export function renderModalFilterList() {
html += `<div class="pp-filter-card${isExpanded ? ' expanded' : ''}" data-filter-index="${index}">
<div class="pp-filter-card-header" onclick="toggleFilterExpand(${index})">
<span class="pp-filter-drag-handle" title="${t('filters.drag_to_reorder')}">&#x2807;</span>
<span class="pp-filter-card-chevron">${isExpanded ? '&#x25BC;' : '&#x25B6;'}</span>
<span class="pp-filter-card-name">${escapeHtml(filterName)}</span>
${summary ? `<span class="pp-filter-card-summary">${escapeHtml(summary)}</span>` : ''}
<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' : ''}>&#x25B2;</button>
<button type="button" class="btn-filter-action" onclick="moveFilter(${index}, 1)" title="${t('filters.move_down')}" ${index === _modalFilters.length - 1 ? 'disabled' : ''}>&#x25BC;</button>
<button type="button" class="btn-filter-action btn-filter-remove" onclick="removeFilter(${index})" title="${t('filters.remove')}">&#x2715;</button>
</div>
</div>
@@ -1988,6 +1987,14 @@ export function renderModalFilterList() {
${options}
</select>
</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 {
html += `<div class="pp-filter-option">
<label for="${inputId}">
@@ -2006,6 +2013,161 @@ export function renderModalFilterList() {
});
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() {
@@ -2065,6 +2227,8 @@ export function updateFilterOption(filterIndex, optionKey, value) {
fi.options[optionKey] = !!value;
} else if (optDef && optDef.type === 'select') {
fi.options[optionKey] = String(value);
} else if (optDef && optDef.type === 'string') {
fi.options[optionKey] = String(value);
} else if (optDef && optDef.type === 'int') {
fi.options[optionKey] = parseInt(value);
} else {

View File

@@ -348,8 +348,7 @@
"filters.select_type": "Select filter type...",
"filters.add": "Add Filter",
"filters.remove": "Remove",
"filters.move_up": "Move Up",
"filters.move_down": "Move Down",
"filters.drag_to_reorder": "Drag to reorder",
"filters.empty": "No filters added. Use the selector below to add filters.",
"filters.brightness": "Brightness",
"filters.saturation": "Saturation",
@@ -360,6 +359,9 @@
"filters.flip": "Flip",
"filters.color_correction": "Color Correction",
"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_placeholder": "Describe this template...",
"postprocessing.created": "Template created successfully",

View File

@@ -348,8 +348,7 @@
"filters.select_type": "Выберите тип фильтра...",
"filters.add": "Добавить фильтр",
"filters.remove": "Удалить",
"filters.move_up": "Вверх",
"filters.move_down": "Вниз",
"filters.drag_to_reorder": "Перетащите для изменения порядка",
"filters.empty": "Фильтры не добавлены. Используйте селектор ниже для добавления.",
"filters.brightness": "Яркость",
"filters.saturation": "Насыщенность",
@@ -360,6 +359,9 @@
"filters.flip": "Отражение",
"filters.color_correction": "Цветокоррекция",
"filters.filter_template": "Шаблон фильтров",
"filters.frame_interpolation": "Интерполяция кадров",
"filters.noise_gate": "Шумоподавление",
"filters.palette_quantization": "Квантизация палитры",
"postprocessing.description_label": "Описание (необязательно):",
"postprocessing.description_placeholder": "Опишите этот шаблон...",
"postprocessing.created": "Шаблон успешно создан",

View File

@@ -348,8 +348,7 @@
"filters.select_type": "选择滤镜类型...",
"filters.add": "添加滤镜",
"filters.remove": "移除",
"filters.move_up": "上移",
"filters.move_down": "下移",
"filters.drag_to_reorder": "拖动以重新排序",
"filters.empty": "尚未添加滤镜。使用下方选择器添加滤镜。",
"filters.brightness": "亮度",
"filters.saturation": "饱和度",
@@ -360,6 +359,9 @@
"filters.flip": "翻转",
"filters.color_correction": "色彩校正",
"filters.filter_template": "滤镜模板",
"filters.frame_interpolation": "帧插值",
"filters.noise_gate": "噪声门",
"filters.palette_quantization": "调色板量化",
"postprocessing.description_label": "描述(可选):",
"postprocessing.description_placeholder": "描述此模板...",
"postprocessing.created": "模板创建成功",