You can edit the below JavaScript code to customize the image tool.
Apply Changes
async function processImage(originalImg, filmStock = "kodak_portra_400") {
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d', { willReadFrequently: true }); // Optimization for frequent getImageData/putImageData
canvas.width = originalImg.naturalWidth;
canvas.height = originalImg.naturalHeight;
ctx.drawImage(originalImg, 0, 0, canvas.width, canvas.height);
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
const data = imageData.data;
// Helper function to clamp values between 0 and 255
const clamp = (value) => Math.max(0, Math.min(255, Math.round(value)));
// Helper for contrast adjustment
// uiContrast: -100 to 100. 0 is no change.
const adjustContrast = (value, uiContrast) => {
if (uiContrast === 0) return value;
// The formula expects contrast in range -255 to 255.
// Let's map uiContrast [-100, 100] to a suitable internal range, e.g. by multiplying.
// A smaller multiplier might be better to prevent extreme changes.
// Let's use the common formula where factor depends on contrast.
// factor = (259 * (c + 255)) / (255 * (259 - c)); where c is typically -255 to 255
// If uiContrast is -100 to 100:
const c = uiContrast; // Let's assume this range is directly usable for a milder effect.
const factor = (259 * (c + 255)) / (255 * (259 - c));
return clamp(factor * (value - 128) + 128);
};
// Helper for saturation adjustment
// saturationFactor: 0.0 (grayscale) to 2.0+ (super saturated). 1.0 is no change.
const adjustSaturation = (r, g, b, saturationFactor) => {
if (saturationFactor === 1.0) return [r, g, b];
const gray = 0.299 * r + 0.587 * g + 0.114 * b; // Luminance
return [
clamp(gray + saturationFactor * (r - gray)),
clamp(gray + saturationFactor * (g - gray)),
clamp(gray + saturationFactor * (b - gray))
];
};
for (let i = 0; i < data.length; i += 4) {
let r = data[i];
let g = data[i + 1];
let b = data[i + 2];
const origR = r;
const origG = g;
const origB = b;
let processedR = r;
let processedG = g;
let processedB = b;
switch (filmStock.toLowerCase()) {
case "kodak_portra_400": {
// Warm tones, good skin tones, slightly desaturated greens/blues, smooth roll-off.
processedR = clamp(r * 1.1 + 10);
processedG = clamp(g * 1.05 + 5);
processedB = clamp(b * 0.92 - 5); // Slightly less blue reduction
// Apply contrast *after* color shifts
processedR = adjustContrast(processedR, -25);
processedG = adjustContrast(processedG, -25);
processedB = adjustContrast(processedB, -25);
// Desaturate blues and greens a bit using original luminance as reference
const luma = 0.299 * origR + 0.587 * origG + 0.114 * origB;
[processedR, processedG, processedB] = adjustSaturation(processedR, processedG, processedB, 0.9); // Overall slight desat
// Specific desaturation for greens/blues
processedG = clamp(luma + 0.85 * (processedG - luma));
processedB = clamp(luma + 0.75 * (processedB - luma));
// Skin tone protection
if (processedR > processedG && processedR > processedB && processedG > processedB * 0.8) {
processedR = clamp(processedR * 0.97); // Less red
processedG = clamp(processedG * 1.03); // Slightly more green
}
break;
}
case "fuji_velvia_50": {
// High saturation, high contrast, vivid reds/greens.
[processedR, processedG, processedB] = adjustSaturation(r, g, b, 1.45);
processedR = adjustContrast(processedR, 30);
processedG = adjustContrast(processedG, 30);
processedB = adjustContrast(processedB, 30);
// Boost reds and greens more
processedR = clamp(processedR * 1.12);
processedG = clamp(processedG * 1.12);
processedB = clamp(processedB * 0.93);
break;
}
case "ilford_hp5_plus_400": {
// B&W, good contrast.
let gray = 0.299 * r + 0.587 * g + 0.114 * b;
gray = adjustContrast(gray, 35);
// Add grain
const grainAmount = 12;
const grain = (Math.random() * 2 - 1) * grainAmount;
processedR = processedG = processedB = clamp(gray + grain);
break;
}
case "fuji_pro_400h": {
// Slightly cool, teal-ish greens, good skin tones.
processedR = clamp(r * 0.96 - 3);
processedG = clamp(g * 1.04); // Boost greens
processedB = clamp(b * 1.08 + 7); // Boost blues for coolness
// Shift greens towards teal if green is dominant
if (origG > origR && origG > origB) {
processedG = clamp(processedG * 1.03);
processedB = clamp(processedB * 1.03); // Add blue to green
}
[processedR, processedG, processedB] = adjustSaturation(processedR, processedG, processedB, 1.15);
processedR = adjustContrast(processedR, 15);
processedG = adjustContrast(processedG, 15);
processedB = adjustContrast(processedB, 15);
break;
}
case "cinestill_800t": {
// Tungsten balanced, blue shadows, warm/reddish highlights, halation idea.
let tempR = r, tempG = g, tempB = b;
const luminance = 0.299 * origR + 0.587 * origG + 0.114 * origB;
if (luminance < 70) { // Shadows
tempB = clamp(b * 1.25 + 20);
tempR = clamp(r * 0.80 - 5);
tempG = clamp(g * 0.88);
} else if (luminance > 190) { // Highlights
tempR = clamp(r * 1.20 + 15);
tempG = clamp(g * 1.08);
tempB = clamp(b * 0.80 - 10);
} else { // Midtones - subtle shifts
tempB = clamp(b * 1.08);
tempR = clamp(r * 0.96);
}
[processedR, processedG, processedB] = adjustSaturation(tempR, tempG, tempB, 1.25);
processedR = adjustContrast(processedR, 20);
processedG = adjustContrast(processedG, 20);
processedB = adjustContrast(processedB, 20);
// "Halation" (crude: make bright areas redder/more orange)
if (origR > 210 && origG > 190 && origB > 170) {
processedR = clamp(processedR * 1.15 + 25);
processedG = clamp(processedG * 0.95); // Reduce green in brightest spots
}
break;
}
case "sepia": {
const gray = 0.299 * r + 0.587 * g + 0.114 * b;
processedR = clamp(gray + 45); // Increased depth
processedG = clamp(gray + 20); // Increased depth
processedB = clamp(gray - 15); // Less blue reduction
break;
}
default: // Unknown film stock, or "none"
// No changes applied, keeps original colors
break;
}
data[i] = processedR;
data[i + 1] = processedG;
data[i + 2] = processedB;
// Alpha channel (data[i+3]) remains unchanged
}
ctx.putImageData(imageData, 0, 0);
return canvas;
}
Apply Changes