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.
164 lines
4.5 KiB
TypeScript
164 lines
4.5 KiB
TypeScript
export interface GradientStop {
|
|
position: number;
|
|
color: string;
|
|
}
|
|
|
|
export interface GradientMapSettings {
|
|
stops: GradientStop[];
|
|
dither: boolean;
|
|
reverse: boolean;
|
|
}
|
|
|
|
export const DEFAULT_GRADIENT_MAP: GradientMapSettings = {
|
|
stops: [
|
|
{ position: 0, color: '#000000' },
|
|
{ position: 100, color: '#ffffff' },
|
|
],
|
|
dither: false,
|
|
reverse: false,
|
|
};
|
|
|
|
export const GRADIENT_MAP_PRESETS = {
|
|
blackWhite: [
|
|
{ position: 0, color: '#000000' },
|
|
{ position: 100, color: '#ffffff' },
|
|
],
|
|
sepiaTone: [
|
|
{ position: 0, color: '#1a0f00' },
|
|
{ position: 50, color: '#8b6914' },
|
|
{ position: 100, color: '#ffe7b3' },
|
|
],
|
|
duotoneBlueOrange: [
|
|
{ position: 0, color: '#001f4d' },
|
|
{ position: 100, color: '#ff8c00' },
|
|
],
|
|
duotonePurpleTeal: [
|
|
{ position: 0, color: '#2d1b4e' },
|
|
{ position: 100, color: '#00d4aa' },
|
|
],
|
|
sunset: [
|
|
{ position: 0, color: '#1a0533' },
|
|
{ position: 33, color: '#6b1839' },
|
|
{ position: 66, color: '#d44d1b' },
|
|
{ position: 100, color: '#ffd700' },
|
|
],
|
|
coolBlue: [
|
|
{ position: 0, color: '#000033' },
|
|
{ position: 50, color: '#0066cc' },
|
|
{ position: 100, color: '#99ccff' },
|
|
],
|
|
warmRed: [
|
|
{ position: 0, color: '#1a0000' },
|
|
{ position: 50, color: '#cc3300' },
|
|
{ position: 100, color: '#ffcc99' },
|
|
],
|
|
greenForest: [
|
|
{ position: 0, color: '#001a00' },
|
|
{ position: 50, color: '#336600' },
|
|
{ position: 100, color: '#99cc66' },
|
|
],
|
|
infrared: [
|
|
{ position: 0, color: '#000000' },
|
|
{ position: 25, color: '#330066' },
|
|
{ position: 50, color: '#ff0066' },
|
|
{ position: 75, color: '#ffcc00' },
|
|
{ position: 100, color: '#ffffff' },
|
|
],
|
|
thermal: [
|
|
{ position: 0, color: '#000033' },
|
|
{ position: 25, color: '#6600cc' },
|
|
{ position: 50, color: '#ff0000' },
|
|
{ position: 75, color: '#ffff00' },
|
|
{ position: 100, color: '#ffffff' },
|
|
],
|
|
};
|
|
|
|
function parseColor(color: string): { r: number; g: number; b: number } {
|
|
const match = color.match(/^#([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i);
|
|
if (match) {
|
|
return {
|
|
r: parseInt(match[1], 16),
|
|
g: parseInt(match[2], 16),
|
|
b: parseInt(match[3], 16),
|
|
};
|
|
}
|
|
return { r: 0, g: 0, b: 0 };
|
|
}
|
|
|
|
function interpolateGradient(
|
|
stops: GradientStop[],
|
|
position: number
|
|
): { r: number; g: number; b: number } {
|
|
if (stops.length === 0) return { r: 0, g: 0, b: 0 };
|
|
if (stops.length === 1) return parseColor(stops[0].color);
|
|
|
|
const sortedStops = [...stops].sort((a, b) => a.position - b.position);
|
|
|
|
if (position <= sortedStops[0].position) {
|
|
return parseColor(sortedStops[0].color);
|
|
}
|
|
if (position >= sortedStops[sortedStops.length - 1].position) {
|
|
return parseColor(sortedStops[sortedStops.length - 1].color);
|
|
}
|
|
|
|
for (let i = 0; i < sortedStops.length - 1; i++) {
|
|
const stop1 = sortedStops[i];
|
|
const stop2 = sortedStops[i + 1];
|
|
|
|
if (position >= stop1.position && position <= stop2.position) {
|
|
const t = (position - stop1.position) / (stop2.position - stop1.position);
|
|
const c1 = parseColor(stop1.color);
|
|
const c2 = parseColor(stop2.color);
|
|
|
|
return {
|
|
r: Math.round(c1.r + (c2.r - c1.r) * t),
|
|
g: Math.round(c1.g + (c2.g - c1.g) * t),
|
|
b: Math.round(c1.b + (c2.b - c1.b) * t),
|
|
};
|
|
}
|
|
}
|
|
|
|
return parseColor(sortedStops[sortedStops.length - 1].color);
|
|
}
|
|
|
|
function getLuminance(r: number, g: number, b: number): number {
|
|
return (r * 0.299 + g * 0.587 + b * 0.114) / 255;
|
|
}
|
|
|
|
export function applyGradientMap(imageData: ImageData, settings: GradientMapSettings): ImageData {
|
|
const { width, height, data } = imageData;
|
|
const resultData = new Uint8ClampedArray(data.length);
|
|
|
|
const lookupTable: Array<{ r: number; g: number; b: number }> = [];
|
|
for (let i = 0; i < 256; i++) {
|
|
let position = (i / 255) * 100;
|
|
if (settings.reverse) {
|
|
position = 100 - position;
|
|
}
|
|
lookupTable[i] = interpolateGradient(settings.stops, position);
|
|
}
|
|
|
|
for (let i = 0; i < data.length; i += 4) {
|
|
const r = data[i];
|
|
const g = data[i + 1];
|
|
const b = data[i + 2];
|
|
const a = data[i + 3];
|
|
|
|
let luminance = getLuminance(r, g, b);
|
|
|
|
if (settings.dither) {
|
|
const noise = (Math.random() - 0.5) * (1 / 255);
|
|
luminance = Math.max(0, Math.min(1, luminance + noise));
|
|
}
|
|
|
|
const idx = Math.round(luminance * 255);
|
|
const mappedColor = lookupTable[idx];
|
|
|
|
resultData[i] = mappedColor.r;
|
|
resultData[i + 1] = mappedColor.g;
|
|
resultData[i + 2] = mappedColor.b;
|
|
resultData[i + 3] = a;
|
|
}
|
|
|
|
return new ImageData(resultData, width, height);
|
|
}
|