feat(web): гистограммы каналов в редакторе кривой
График тональной кривой теперь показывает гистограмму фото: выбранный канал — своим цветом, мастер-канал (RGB) — всеми тремя слоями. Гистограмма считается в браузере по уменьшенной копии базового превью и не влияет на скорость перерисовки.
This commit is contained in:
@@ -53,6 +53,80 @@
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Гистограмма каналов считается по базовому превью фото.
|
||||||
|
type Histogram = { red: number[]; green: number[]; blue: number[] };
|
||||||
|
let histogram = $state<Histogram | null>(null);
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
const url = photo.preview_url;
|
||||||
|
if (!url) {
|
||||||
|
histogram = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let cancelled = false;
|
||||||
|
const img = new Image();
|
||||||
|
img.onload = () => {
|
||||||
|
if (cancelled) return;
|
||||||
|
histogram = computeHistogram(img);
|
||||||
|
};
|
||||||
|
img.src = url;
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Считает гистограмму каналов по уменьшенной копии фото. */
|
||||||
|
function computeHistogram(img: HTMLImageElement): Histogram | null {
|
||||||
|
const width = 256;
|
||||||
|
const height = Math.max(1, Math.round((img.naturalHeight / img.naturalWidth) * width));
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.width = width;
|
||||||
|
canvas.height = height;
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
if (!ctx) return null;
|
||||||
|
ctx.drawImage(img, 0, 0, width, height);
|
||||||
|
const data = ctx.getImageData(0, 0, width, height).data;
|
||||||
|
const r = new Array<number>(256).fill(0);
|
||||||
|
const g = new Array<number>(256).fill(0);
|
||||||
|
const b = new Array<number>(256).fill(0);
|
||||||
|
for (let i = 0; i < data.length; i += 4) {
|
||||||
|
r[data[i]]++;
|
||||||
|
g[data[i + 1]]++;
|
||||||
|
b[data[i + 2]]++;
|
||||||
|
}
|
||||||
|
let max = 0;
|
||||||
|
for (const values of [r, g, b]) {
|
||||||
|
for (const v of values) if (v > max) max = v;
|
||||||
|
}
|
||||||
|
if (!max) return null;
|
||||||
|
const norm = (values: number[]) => values.map((v) => v / max);
|
||||||
|
return { red: norm(r), green: norm(g), blue: norm(b) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Каналы, которые показываем на графике: у мастер-канала — все три. */
|
||||||
|
const visibleChannels = $derived<CurveChannel[]>(
|
||||||
|
channel === 'rgb' ? ['red', 'green', 'blue'] : [channel]
|
||||||
|
);
|
||||||
|
|
||||||
|
/** Значения гистограммы для канала. */
|
||||||
|
function histogramValues(ch: CurveChannel): number[] {
|
||||||
|
if (!histogram) return [];
|
||||||
|
if (ch === 'rgb') return histogram.red;
|
||||||
|
return histogram[ch];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Гистограмма канала в виде вертикальных столбиков. */
|
||||||
|
function histogramPath(values: number[]): string {
|
||||||
|
const parts: string[] = [];
|
||||||
|
const last = values.length - 1;
|
||||||
|
for (let i = 0; i <= last; i++) {
|
||||||
|
const x = ((i / last) * 100).toFixed(2);
|
||||||
|
const y = (100 - values[i] * 100).toFixed(2);
|
||||||
|
parts.push(`M${x},100 L${x},${y}`);
|
||||||
|
}
|
||||||
|
return parts.join(' ');
|
||||||
|
}
|
||||||
|
|
||||||
// Правки уходят на сервер не сразу: пока пользователь крутит ползунки,
|
// Правки уходят на сервер не сразу: пока пользователь крутит ползунки,
|
||||||
// фото перерисовывает браузер, а серверу достаётся уже settled-версия.
|
// фото перерисовывает браузер, а серверу достаётся уже settled-версия.
|
||||||
const SAVE_DELAY = 10_000;
|
const SAVE_DELAY = 10_000;
|
||||||
@@ -347,6 +421,16 @@
|
|||||||
onpointerleave={onPointUp}
|
onpointerleave={onPointUp}
|
||||||
>
|
>
|
||||||
<rect x="0" y="0" width="100" height="100" class="bg-bg" />
|
<rect x="0" y="0" width="100" height="100" class="bg-bg" />
|
||||||
|
{#if histogram}
|
||||||
|
{#each visibleChannels as ch (ch)}
|
||||||
|
<path
|
||||||
|
d={histogramPath(histogramValues(ch))}
|
||||||
|
fill="none"
|
||||||
|
stroke-width="0.4"
|
||||||
|
style="stroke: {CHANNEL_COLORS[ch]}; opacity: {ch === 'rgb' ? 0.45 : 0.7}"
|
||||||
|
/>
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
{#each [25, 50, 75] as step (step)}
|
{#each [25, 50, 75] as step (step)}
|
||||||
<line x1={step} y1="0" x2={step} y2="100" class="stroke-border" stroke-width="0.4" />
|
<line x1={step} y1="0" x2={step} y2="100" class="stroke-border" stroke-width="0.4" />
|
||||||
<line x1="0" y1={step} x2="100" y2={step} class="stroke-border" stroke-width="0.4" />
|
<line x1="0" y1={step} x2="100" y2={step} class="stroke-border" stroke-width="0.4" />
|
||||||
|
|||||||
@@ -779,10 +779,11 @@ SHALL очищаться. При отсутствии сети приложен
|
|||||||
### Requirement: Вкладка «Редактирование»
|
### Requirement: Вкладка «Редактирование»
|
||||||
Правая панель фото SHALL содержать вкладку «Редактирование» с инструментом «Свет»:
|
Правая панель фото SHALL содержать вкладку «Редактирование» с инструментом «Свет»:
|
||||||
ползунки экспозиции, контраста, светов, теней, белых и чёрных, а также точечную кривую
|
ползунки экспозиции, контраста, светов, теней, белых и чёрных, а также точечную кривую
|
||||||
с выбором канала. Правки SHALL применяться к фото сразу и отправляться на сервер после
|
с выбором канала. График кривой SHALL показывать гистограмму каналов фото: выбранный
|
||||||
десяти секунд без изменений либо при переключении фото. Переключатель «Сброс» SHALL
|
канал — своим цветом, мастер-канал — всеми тремя. Правки SHALL применяться к фото сразу и
|
||||||
удалять параметры правок и отредактированную версию фото. Сетка и просмотр SHALL
|
отправляться на сервер после десяти секунд без изменений либо при переключении фото.
|
||||||
показывать отредактированную версию фото, когда она отрендерена.
|
Переключатель «Сброс» SHALL удалять параметры правок и отредактированную версию фото.
|
||||||
|
Сетка и просмотр SHALL показывать отредактированную версию фото, когда она отрендерена.
|
||||||
|
|
||||||
#### Scenario: Отложенная отправка
|
#### Scenario: Отложенная отправка
|
||||||
- **WHEN** пользователь меняет ползунок и не трогает панель десять секунд
|
- **WHEN** пользователь меняет ползунок и не трогает панель десять секунд
|
||||||
|
|||||||
Reference in New Issue
Block a user