Vendored Augani/openreel-video (MIT) into services/editor and wired it to the MAM. Editor runs as its own container on port 47435. Library assets pull in via ?asset=<uuid>; render exports route back via POST /api/v1/upload/simple. Sidebar Editor link on every page; Edit button on every preview modal. See services/editor/INTEGRATION.md for the patch map.
305 lines
8.1 KiB
TypeScript
305 lines
8.1 KiB
TypeScript
export interface HistogramData {
|
|
red: Uint32Array;
|
|
green: Uint32Array;
|
|
blue: Uint32Array;
|
|
luminosity: Uint32Array;
|
|
}
|
|
|
|
export interface HistogramStatistics {
|
|
mean: number;
|
|
stdDev: number;
|
|
median: number;
|
|
min: number;
|
|
max: number;
|
|
pixelCount: number;
|
|
shadowsClipped: number;
|
|
highlightsClipped: number;
|
|
}
|
|
|
|
export interface HistogramResult {
|
|
data: HistogramData;
|
|
statistics: {
|
|
red: HistogramStatistics;
|
|
green: HistogramStatistics;
|
|
blue: HistogramStatistics;
|
|
luminosity: HistogramStatistics;
|
|
};
|
|
}
|
|
|
|
export interface ColorInfo {
|
|
rgb: { r: number; g: number; b: number };
|
|
hsb: { h: number; s: number; b: number };
|
|
hsl: { h: number; s: number; l: number };
|
|
lab: { l: number; a: number; b: number };
|
|
cmyk: { c: number; m: number; y: number; k: number };
|
|
hex: string;
|
|
}
|
|
|
|
function calculateStatistics(histogram: Uint32Array, totalPixels: number): HistogramStatistics {
|
|
let sum = 0;
|
|
let min = 255;
|
|
let max = 0;
|
|
let pixelCount = 0;
|
|
|
|
for (let i = 0; i < 256; i++) {
|
|
const count = histogram[i];
|
|
if (count > 0) {
|
|
sum += i * count;
|
|
pixelCount += count;
|
|
if (i < min) min = i;
|
|
if (i > max) max = i;
|
|
}
|
|
}
|
|
|
|
const mean = pixelCount > 0 ? sum / pixelCount : 0;
|
|
|
|
let varianceSum = 0;
|
|
for (let i = 0; i < 256; i++) {
|
|
const count = histogram[i];
|
|
if (count > 0) {
|
|
varianceSum += count * Math.pow(i - mean, 2);
|
|
}
|
|
}
|
|
const stdDev = pixelCount > 0 ? Math.sqrt(varianceSum / pixelCount) : 0;
|
|
|
|
let medianCount = 0;
|
|
let median = 0;
|
|
const halfCount = pixelCount / 2;
|
|
for (let i = 0; i < 256; i++) {
|
|
medianCount += histogram[i];
|
|
if (medianCount >= halfCount) {
|
|
median = i;
|
|
break;
|
|
}
|
|
}
|
|
|
|
const shadowsClipped = (histogram[0] / totalPixels) * 100;
|
|
const highlightsClipped = (histogram[255] / totalPixels) * 100;
|
|
|
|
return {
|
|
mean,
|
|
stdDev,
|
|
median,
|
|
min: pixelCount > 0 ? min : 0,
|
|
max: pixelCount > 0 ? max : 0,
|
|
pixelCount,
|
|
shadowsClipped,
|
|
highlightsClipped,
|
|
};
|
|
}
|
|
|
|
export function calculateHistogram(imageData: ImageData): HistogramResult {
|
|
const { data } = imageData;
|
|
|
|
const histogramData: HistogramData = {
|
|
red: new Uint32Array(256),
|
|
green: new Uint32Array(256),
|
|
blue: new Uint32Array(256),
|
|
luminosity: new Uint32Array(256),
|
|
};
|
|
|
|
const totalPixels = data.length / 4;
|
|
|
|
for (let i = 0; i < data.length; i += 4) {
|
|
const r = data[i];
|
|
const g = data[i + 1];
|
|
const b = data[i + 2];
|
|
|
|
histogramData.red[r]++;
|
|
histogramData.green[g]++;
|
|
histogramData.blue[b]++;
|
|
|
|
const luminosity = Math.round(r * 0.299 + g * 0.587 + b * 0.114);
|
|
histogramData.luminosity[luminosity]++;
|
|
}
|
|
|
|
return {
|
|
data: histogramData,
|
|
statistics: {
|
|
red: calculateStatistics(histogramData.red, totalPixels),
|
|
green: calculateStatistics(histogramData.green, totalPixels),
|
|
blue: calculateStatistics(histogramData.blue, totalPixels),
|
|
luminosity: calculateStatistics(histogramData.luminosity, totalPixels),
|
|
},
|
|
};
|
|
}
|
|
|
|
export function getColorInfo(r: number, g: number, b: number): ColorInfo {
|
|
const rNorm = r / 255;
|
|
const gNorm = g / 255;
|
|
const bNorm = b / 255;
|
|
|
|
const max = Math.max(rNorm, gNorm, bNorm);
|
|
const min = Math.min(rNorm, gNorm, bNorm);
|
|
const delta = max - min;
|
|
|
|
let h = 0;
|
|
if (delta !== 0) {
|
|
if (max === rNorm) {
|
|
h = ((gNorm - bNorm) / delta + (gNorm < bNorm ? 6 : 0)) / 6;
|
|
} else if (max === gNorm) {
|
|
h = ((bNorm - rNorm) / delta + 2) / 6;
|
|
} else {
|
|
h = ((rNorm - gNorm) / delta + 4) / 6;
|
|
}
|
|
}
|
|
|
|
const l = (max + min) / 2;
|
|
const sHsl = delta === 0 ? 0 : delta / (1 - Math.abs(2 * l - 1));
|
|
|
|
const sBrightness = max === 0 ? 0 : delta / max;
|
|
|
|
const k = 1 - max;
|
|
const c = max === 0 ? 0 : (1 - rNorm - k) / (1 - k);
|
|
const m = max === 0 ? 0 : (1 - gNorm - k) / (1 - k);
|
|
const y = max === 0 ? 0 : (1 - bNorm - k) / (1 - k);
|
|
|
|
const xyzR = rNorm > 0.04045 ? Math.pow((rNorm + 0.055) / 1.055, 2.4) : rNorm / 12.92;
|
|
const xyzG = gNorm > 0.04045 ? Math.pow((gNorm + 0.055) / 1.055, 2.4) : gNorm / 12.92;
|
|
const xyzB = bNorm > 0.04045 ? Math.pow((bNorm + 0.055) / 1.055, 2.4) : bNorm / 12.92;
|
|
|
|
const x = (xyzR * 0.4124564 + xyzG * 0.3575761 + xyzB * 0.1804375) / 0.95047;
|
|
const yVal = xyzR * 0.2126729 + xyzG * 0.7151522 + xyzB * 0.0721750;
|
|
const z = (xyzR * 0.0193339 + xyzG * 0.1191920 + xyzB * 0.9503041) / 1.08883;
|
|
|
|
const f = (t: number) => t > 0.008856 ? Math.pow(t, 1 / 3) : 7.787 * t + 16 / 116;
|
|
|
|
const labL = 116 * f(yVal) - 16;
|
|
const labA = 500 * (f(x) - f(yVal));
|
|
const labB = 200 * (f(yVal) - f(z));
|
|
|
|
const hex = '#' +
|
|
r.toString(16).padStart(2, '0') +
|
|
g.toString(16).padStart(2, '0') +
|
|
b.toString(16).padStart(2, '0');
|
|
|
|
return {
|
|
rgb: { r, g, b },
|
|
hsb: {
|
|
h: Math.round(h * 360),
|
|
s: Math.round(sBrightness * 100),
|
|
b: Math.round(max * 100),
|
|
},
|
|
hsl: {
|
|
h: Math.round(h * 360),
|
|
s: Math.round(sHsl * 100),
|
|
l: Math.round(l * 100),
|
|
},
|
|
lab: {
|
|
l: Math.round(labL),
|
|
a: Math.round(labA),
|
|
b: Math.round(labB),
|
|
},
|
|
cmyk: {
|
|
c: Math.round(c * 100),
|
|
m: Math.round(m * 100),
|
|
y: Math.round(y * 100),
|
|
k: Math.round(k * 100),
|
|
},
|
|
hex,
|
|
};
|
|
}
|
|
|
|
export function renderHistogram(
|
|
ctx: CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D,
|
|
histogram: Uint32Array,
|
|
color: string,
|
|
width: number,
|
|
height: number,
|
|
logarithmic: boolean = false
|
|
): void {
|
|
const maxValue = Math.max(...histogram);
|
|
if (maxValue === 0) return;
|
|
|
|
ctx.fillStyle = color;
|
|
ctx.globalAlpha = 0.7;
|
|
|
|
const barWidth = width / 256;
|
|
|
|
for (let i = 0; i < 256; i++) {
|
|
let normalizedValue: number;
|
|
if (logarithmic && histogram[i] > 0) {
|
|
normalizedValue = Math.log10(histogram[i] + 1) / Math.log10(maxValue + 1);
|
|
} else {
|
|
normalizedValue = histogram[i] / maxValue;
|
|
}
|
|
|
|
const barHeight = normalizedValue * height;
|
|
ctx.fillRect(i * barWidth, height - barHeight, barWidth, barHeight);
|
|
}
|
|
|
|
ctx.globalAlpha = 1;
|
|
}
|
|
|
|
export function autoLevels(imageData: ImageData, clipPercent: number = 0.1): ImageData {
|
|
const { width, height, data } = imageData;
|
|
const resultData = new Uint8ClampedArray(data.length);
|
|
|
|
const histogram = calculateHistogram(imageData);
|
|
const totalPixels = data.length / 4;
|
|
const clipPixels = Math.round(totalPixels * (clipPercent / 100));
|
|
|
|
const findClipPoint = (hist: Uint32Array, fromStart: boolean): number => {
|
|
let count = 0;
|
|
if (fromStart) {
|
|
for (let i = 0; i < 256; i++) {
|
|
count += hist[i];
|
|
if (count > clipPixels) return i;
|
|
}
|
|
return 0;
|
|
} else {
|
|
for (let i = 255; i >= 0; i--) {
|
|
count += hist[i];
|
|
if (count > clipPixels) return i;
|
|
}
|
|
return 255;
|
|
}
|
|
};
|
|
|
|
const channels = ['red', 'green', 'blue'] as const;
|
|
const adjustments = channels.map((channel) => {
|
|
const hist = histogram.data[channel];
|
|
const inputBlack = findClipPoint(hist, true);
|
|
const inputWhite = findClipPoint(hist, false);
|
|
return { inputBlack, inputWhite };
|
|
});
|
|
|
|
for (let i = 0; i < data.length; i += 4) {
|
|
for (let c = 0; c < 3; c++) {
|
|
const { inputBlack, inputWhite } = adjustments[c];
|
|
const range = inputWhite - inputBlack || 1;
|
|
const value = data[i + c];
|
|
const adjusted = ((value - inputBlack) / range) * 255;
|
|
resultData[i + c] = Math.max(0, Math.min(255, Math.round(adjusted)));
|
|
}
|
|
resultData[i + 3] = data[i + 3];
|
|
}
|
|
|
|
return new ImageData(resultData, width, height);
|
|
}
|
|
|
|
export function autoContrast(imageData: ImageData): ImageData {
|
|
const { width, height, data } = imageData;
|
|
const resultData = new Uint8ClampedArray(data.length);
|
|
|
|
let minLum = 255;
|
|
let maxLum = 0;
|
|
|
|
for (let i = 0; i < data.length; i += 4) {
|
|
const lum = Math.round(data[i] * 0.299 + data[i + 1] * 0.587 + data[i + 2] * 0.114);
|
|
if (lum < minLum) minLum = lum;
|
|
if (lum > maxLum) maxLum = lum;
|
|
}
|
|
|
|
const range = maxLum - minLum || 1;
|
|
|
|
for (let i = 0; i < data.length; i += 4) {
|
|
for (let c = 0; c < 3; c++) {
|
|
const adjusted = ((data[i + c] - minLum) / range) * 255;
|
|
resultData[i + c] = Math.max(0, Math.min(255, Math.round(adjusted)));
|
|
}
|
|
resultData[i + 3] = data[i + 3];
|
|
}
|
|
|
|
return new ImageData(resultData, width, height);
|
|
}
|