--[[
Lumetric Corrector für OBS Studio
Farbkorrektur und Color Grading inspiriert von Adobe Lumetri Color
Autor: TheGeekFreaks
Version: 3.0.0
Lizenz: GPLv3
Quelle: https://github.com/The-Geek-Freaks/lumetric_corrector
]]
local obs = obslua
local VERSION = "3.0.0"
local DEBUG = false
-- ---------------------------------------------------------------------------
-- Logging
-- ---------------------------------------------------------------------------
local function log_debug(msg) if DEBUG then obs.blog(obs.LOG_INFO, "[Lumetric] " .. tostring(msg)) end end
local function log_info(msg) obs.blog(obs.LOG_INFO, "[Lumetric] " .. tostring(msg)) end
local function log_warn(msg) obs.blog(obs.LOG_WARNING, "[Lumetric] " .. tostring(msg)) end
local function log_error(msg) obs.blog(obs.LOG_ERROR, "[Lumetric] " .. tostring(msg)) end
-- ---------------------------------------------------------------------------
-- Übersetzungen (Labels + Tooltips)
-- ---------------------------------------------------------------------------
local L10N = {
["en-US"] = {
lumetric_corrector = "Lumetric Corrector",
-- Gruppen
presets = "Presets", tone = "Tone", fade = "Fade & Lift", wb = "White Balance", color = "Color",
wheels = "Color Wheels (Lift / Gamma / Gain)", split = "Split Toning", fx = "Creative Effects",
vignette = "Vignette", mask = "Mask (apply only inside a region)", lut = "3D LUT", looks = "Looks & LUT Export",
-- Presets
select_preset = "Preset", select_preset_desc = "Choosing a preset applies it immediately. Mask and LUT settings are kept.",
apply_preset = "Apply preset again", reset_all = "Reset all settings",
cat_basic = "── Basic ──", cat_style = "── Style ──", cat_mood = "── Mood ──", cat_era = "── Era ──", cat_creative = "── Creative ──",
-- Regler
exposure = "Exposure", exposure_desc = "Overall exposure in stops. +1 doubles the light, -1 halves it.",
contrast = "Contrast", contrast_desc = "Expands or compresses tones around mid grey.",
brightness = "Brightness", brightness_desc = "Shifts all tones up or down without changing contrast.",
highlights = "Highlights", highlights_desc = "Brightens or recovers the bright parts of the image.",
shadows = "Shadows", shadows_desc = "Lifts or deepens the dark parts of the image.",
whites = "Whites", whites_desc = "Moves the white point. Negative values keep highlights from clipping.",
blacks = "Blacks", blacks_desc = "Moves the black point. Negative values give denser blacks.",
highlight_fade = "Highlight Fade", highlight_fade_desc = "Washes bright areas towards white (bleach look).",
shadow_fade = "Shadow Fade", shadow_fade_desc = "Washes dark areas towards white for a soft, faded look.",
black_lift = "Black Lift", black_lift_desc = "Raises the black point for a matte film look.",
temperature = "Temperature", temperature_desc = "Positive = warmer (orange), negative = cooler (blue).",
tint = "Tint", tint_desc = "Positive = magenta, negative = green.",
saturation = "Saturation", saturation_desc = "Intensity of all colors. -1 gives black & white.",
vibrance = "Vibrance", vibrance_desc = "Boosts muted colors more than already saturated ones.",
shadows_color_r = "Shadows Red", shadows_color_g = "Shadows Green", shadows_color_b = "Shadows Blue",
midtones_color_r = "Midtones Red", midtones_color_g = "Midtones Green", midtones_color_b = "Midtones Blue",
highlights_color_r = "Highlights Red", highlights_color_g = "Highlights Green", highlights_color_b = "Highlights Blue",
wheels_desc = "Adds or removes this color component in the given tonal range.",
ss_r = "Shadows Red", ss_g = "Shadows Green", ss_b = "Shadows Blue", ss_sat = "Shadows Strength",
sh_r = "Highlights Red", sh_g = "Highlights Green", sh_b = "Highlights Blue", sh_sat = "Highlights Strength",
split_color_desc = "Color that is toned into this range. Only visible when the strength is above 0.",
split_sat_desc = "How strongly the toning color is applied.",
sharpen = "Sharpen", sharpen_desc = "Local sharpness (unsharp mask).",
bloom = "Bloom", bloom_desc = "Soft glow around bright areas.",
halation = "Halation", halation_desc = "Warm, reddish glow around highlights, like analog film.",
grain_amount = "Film Grain", grain_amount_desc = "Animated film grain. Stronger in shadows than in highlights.",
grain_size = "Grain Size", grain_size_desc = "Size of the grain in pixels (relative to 1080p).",
vignette_amount = "Amount", vignette_amount_desc = "Darkening of the image corners.",
vignette_radius = "Radius", vignette_radius_desc = "Where the darkening starts. Larger = smaller vignette.",
vignette_feather = "Feather", vignette_feather_desc = "Softness of the vignette edge.",
vignette_shape = "Shape", vignette_shape_desc = "0 = circular, 1 = wide oval.",
mask_shape = "Shape", mask_round = "Ellipse", mask_rect = "Rectangle",
mask_x = "Center X", mask_y = "Center Y", mask_width = "Width", mask_height = "Height",
mask_feather = "Feather", mask_pos_desc = "Position of the mask center (0..1 of the image).",
mask_size_desc = "Half size of the mask (0..1 of the image).", mask_feather_desc = "Soft edge of the mask.",
lut_path = "LUT File", lut_path_desc = ".cube (any size) or .png (OBS 512x512 grid or N*N x N strip). Converted .cube files are cached in the OBS plugin_config folder.",
lut_strength = "LUT Strength", lut_strength_desc = "Blend between the graded image and the LUT result.",
lut_status = "LUT status",
lut_export_path = "LUT export file", lut_export_path_desc = ".cube writes a 33x33x33 LUT, .png writes a 512x512 OBS-compatible LUT.",
export_lut = "Export current look as LUT", export_lut_desc = "Bakes all color settings into a LUT. Spatial effects (bloom, halation, sharpen, grain, vignette, mask) cannot be part of a LUT.",
look_export_path = "Look export file", save_look = "Save look (JSON)",
look_import_path = "Look import file", load_look = "Load look (JSON)",
look_desc = "Looks contain all color settings and can be shared between scenes, profiles and computers.",
info_text = "Lumetric Corrector v%s — Tip: start with a preset, then fine-tune. Bloom, halation, sharpen and grain cost the most GPU time; leave them at 0 if you do not need them.",
},
["de-DE"] = {
lumetric_corrector = "Lumetric Korrektor",
presets = "Voreinstellungen", tone = "Tonwerte", fade = "Ausbleichen & Anheben", wb = "Weißabgleich", color = "Farbe",
wheels = "Farbräder (Lift / Gamma / Gain)", split = "Split-Toning", fx = "Kreative Effekte",
vignette = "Vignette", mask = "Maske (nur innerhalb eines Bereichs anwenden)", lut = "3D LUT", looks = "Looks & LUT-Export",
select_preset = "Voreinstellung", select_preset_desc = "Eine Voreinstellung wird sofort angewendet. Maske und LUT bleiben erhalten.",
apply_preset = "Voreinstellung erneut anwenden", reset_all = "Alle Einstellungen zurücksetzen",
cat_basic = "── Grundlegend ──", cat_style = "── Stil ──", cat_mood = "── Stimmung ──", cat_era = "── Ära ──", cat_creative = "── Kreativ ──",
exposure = "Belichtung", exposure_desc = "Gesamtbelichtung in Blendenstufen. +1 verdoppelt das Licht, -1 halbiert es.",
contrast = "Kontrast", contrast_desc = "Dehnt oder staucht die Tonwerte um Mittelgrau.",
brightness = "Helligkeit", brightness_desc = "Verschiebt alle Tonwerte, ohne den Kontrast zu ändern.",
highlights = "Lichter", highlights_desc = "Hellt die hellen Bildbereiche auf oder holt sie zurück.",
shadows = "Schatten", shadows_desc = "Hebt die dunklen Bildbereiche an oder vertieft sie.",
whites = "Weiß", whites_desc = "Verschiebt den Weißpunkt. Negative Werte verhindern Clipping in den Lichtern.",
blacks = "Schwarz", blacks_desc = "Verschiebt den Schwarzpunkt. Negative Werte ergeben satteres Schwarz.",
highlight_fade = "Lichter ausbleichen", highlight_fade_desc = "Zieht helle Bereiche Richtung Weiß (Bleach-Look).",
shadow_fade = "Schatten ausbleichen", shadow_fade_desc = "Zieht dunkle Bereiche Richtung Weiß für einen weichen, verblassten Look.",
black_lift = "Schwarzwert anheben", black_lift_desc = "Hebt den Schwarzpunkt an, matter Film-Look.",
temperature = "Temperatur", temperature_desc = "Positiv = wärmer (orange), negativ = kühler (blau).",
tint = "Farbton", tint_desc = "Positiv = Magenta, negativ = Grün.",
saturation = "Sättigung", saturation_desc = "Intensität aller Farben. -1 ergibt Schwarz-Weiß.",
vibrance = "Lebendigkeit", vibrance_desc = "Verstärkt blasse Farben stärker als bereits gesättigte.",
shadows_color_r = "Schatten Rot", shadows_color_g = "Schatten Grün", shadows_color_b = "Schatten Blau",
midtones_color_r = "Mitteltöne Rot", midtones_color_g = "Mitteltöne Grün", midtones_color_b = "Mitteltöne Blau",
highlights_color_r = "Lichter Rot", highlights_color_g = "Lichter Grün", highlights_color_b = "Lichter Blau",
wheels_desc = "Fügt diesen Farbanteil im jeweiligen Tonwertbereich hinzu oder entfernt ihn.",
ss_r = "Schatten Rot", ss_g = "Schatten Grün", ss_b = "Schatten Blau", ss_sat = "Schatten Stärke",
sh_r = "Lichter Rot", sh_g = "Lichter Grün", sh_b = "Lichter Blau", sh_sat = "Lichter Stärke",
split_color_desc = "Farbe, die in diesen Bereich getont wird. Erst sichtbar, wenn die Stärke über 0 liegt.",
split_sat_desc = "Wie stark die Tonungsfarbe angewendet wird.",
sharpen = "Schärfen", sharpen_desc = "Lokale Schärfe (Unscharfmaskierung).",
bloom = "Bloom", bloom_desc = "Weiches Leuchten um helle Bereiche.",
halation = "Halation", halation_desc = "Warmer, rötlicher Schimmer um Lichter wie bei analogem Film.",
grain_amount = "Filmkorn", grain_amount_desc = "Animiertes Filmkorn. In Schatten stärker als in Lichtern.",
grain_size = "Korngröße", grain_size_desc = "Größe des Korns in Pixeln (bezogen auf 1080p).",
vignette_amount = "Stärke", vignette_amount_desc = "Abdunkelung der Bildecken.",
vignette_radius = "Radius", vignette_radius_desc = "Ab wo die Abdunkelung beginnt. Größer = kleinere Vignette.",
vignette_feather = "Weichheit", vignette_feather_desc = "Weichheit der Vignettenkante.",
vignette_shape = "Form", vignette_shape_desc = "0 = kreisförmig, 1 = breites Oval.",
mask_shape = "Form", mask_round = "Ellipse", mask_rect = "Rechteck",
mask_x = "Mitte X", mask_y = "Mitte Y", mask_width = "Breite", mask_height = "Höhe",
mask_feather = "Weichheit", mask_pos_desc = "Position der Maskenmitte (0..1 des Bildes).",
mask_size_desc = "Halbe Größe der Maske (0..1 des Bildes).", mask_feather_desc = "Weiche Kante der Maske.",
lut_path = "LUT-Datei", lut_path_desc = ".cube (beliebige Größe) oder .png (OBS-Raster 512x512 oder Streifen N*N x N). Konvertierte .cube-Dateien werden im OBS-Ordner plugin_config zwischengespeichert.",
lut_strength = "LUT-Stärke", lut_strength_desc = "Mischung zwischen gegradetem Bild und LUT-Ergebnis.",
lut_status = "LUT-Status",
lut_export_path = "LUT-Exportdatei", lut_export_path_desc = ".cube schreibt eine 33x33x33-LUT, .png schreibt eine OBS-kompatible 512x512-LUT.",
export_lut = "Aktuellen Look als LUT exportieren", export_lut_desc = "Backt alle Farbeinstellungen in eine LUT. Räumliche Effekte (Bloom, Halation, Schärfen, Korn, Vignette, Maske) können nicht Teil einer LUT sein.",
look_export_path = "Look-Exportdatei", save_look = "Look speichern (JSON)",
look_import_path = "Look-Importdatei", load_look = "Look laden (JSON)",
look_desc = "Looks enthalten alle Farbeinstellungen und lassen sich zwischen Szenen, Profilen und Rechnern austauschen.",
info_text = "Lumetric Korrektor v%s — Tipp: Mit einer Voreinstellung starten, dann feinjustieren. Bloom, Halation, Schärfen und Korn kosten am meisten GPU-Zeit; auf 0 lassen, wenn nicht benötigt.",
},
}
-- Preset-Namen (gleich in beiden Sprachen, außer wo eine Übersetzung sinnvoll ist)
local PRESET_NAMES = {
["en-US"] = {
neutral = "Neutral", warm = "Warm", cool = "Cool", bright = "Bright", dark = "Dark",
contrast_boost = "Contrast Boost", contrast_reduce = "Contrast Reduce",
cinematic = "Cinematic", filmic = "Filmic", dramatic = "Dramatic Contrast", vibrant = "Vibrant", muted = "Muted",
warm_contrast = "Warm Contrast", cool_contrast = "Cool Contrast", bleach_bypass = "Bleach Bypass",
faded_shadows = "Faded Shadows", cross_process = "Cross Process", crisp_clarity = "Crisp Clarity",
highlights_warm = "Warm Highlights", shadows_blue = "Blue Shadows", shadows_green = "Green Shadows",
sunset = "Sunset Glow", moonlight = "Moonlight", vivid_warm = "Vivid Warm", moody_blue = "Moody Blue",
warm_portrait = "Warm Portrait", day_for_night = "Day For Night", night_enhancer = "Night Enhancer",
forest_green = "Forest Green", desert_heat = "Desert Heat", arctic_chill = "Arctic Chill", horror_atmosphere = "Horror Atmosphere",
vintage = "Vintage Film", retro_film = "Retro Film", film_70s = "70s Film", retro_80s = "80s Retro", vhs_90s = "90s VHS",
analog_vhs = "Analog VHS", sepia = "Sepia Tone", bw = "Black & White", bw_high_contrast = "B&W High Contrast",
neo_noir = "Neo Noir", cyberpunk = "Cyberpunk", teal_orange = "Teal & Orange", dreamy_bloom = "Dreamy Bloom",
pastel_dreams = "Pastel Dreams", game_stream = "Game Stream",
},
["de-DE"] = {
neutral = "Neutral", warm = "Warm", cool = "Kühl", bright = "Hell", dark = "Dunkel",
contrast_boost = "Kontrast-Boost", contrast_reduce = "Kontrast-Reduzierung",
cinematic = "Cinematisch", filmic = "Filmisch", dramatic = "Dramatischer Kontrast", vibrant = "Lebendig", muted = "Gedämpft",
warm_contrast = "Warmer Kontrast", cool_contrast = "Kühler Kontrast", bleach_bypass = "Bleach Bypass",
faded_shadows = "Verblasste Schatten", cross_process = "Cross Process", crisp_clarity = "Klare Schärfe",
highlights_warm = "Warme Lichter", shadows_blue = "Blaue Schatten", shadows_green = "Grüne Schatten",
sunset = "Sonnenuntergang", moonlight = "Mondlicht", vivid_warm = "Lebendig Warm", moody_blue = "Moody Blue",
warm_portrait = "Warmes Porträt", day_for_night = "Day For Night", night_enhancer = "Nacht-Aufheller",
forest_green = "Waldgrün", desert_heat = "Wüstenhitze", arctic_chill = "Arktische Kälte", horror_atmosphere = "Horror-Atmosphäre",
vintage = "Vintage-Film", retro_film = "Retro-Film", film_70s = "70er Film", retro_80s = "80er Retro", vhs_90s = "90er VHS",
analog_vhs = "Analog VHS", sepia = "Sepia", bw = "Schwarz-Weiß", bw_high_contrast = "S/W Hochkontrast",
neo_noir = "Neo Noir", cyberpunk = "Cyberpunk", teal_orange = "Teal & Orange", dreamy_bloom = "Traumhafter Bloom",
pastel_dreams = "Pastell-Träume", game_stream = "Spiele-Stream",
},
}
local function current_lang()
local locale = obs.obs_get_locale and obs.obs_get_locale() or "en-US"
if type(locale) == "string" and locale:sub(1, 2):lower() == "de" then
return "de-DE"
end
return "en-US"
end
local function tr(key)
local lang = current_lang()
return (L10N[lang] and L10N[lang][key]) or L10N["en-US"][key] or key
end
local function preset_name(id)
local lang = current_lang()
return (PRESET_NAMES[lang] and PRESET_NAMES[lang][id]) or PRESET_NAMES["en-US"][id] or id
end
-- ---------------------------------------------------------------------------
-- Parameter-Definitionen: eine Tabelle steuert Defaults, UI, Update und Shader
-- key = Settings-Schlüssel, shader = Uniform-Name (nil = Teil eines Vektors)
-- ---------------------------------------------------------------------------
local PARAMS = {
-- Tonwerte
{ key = "exposure", group = "tone", min = -1, max = 1, step = 0.01, def = 0, shader = "exposure", desc = "exposure_desc" },
{ key = "contrast", group = "tone", min = -1, max = 1, step = 0.01, def = 0, shader = "contrast", desc = "contrast_desc" },
{ key = "brightness", group = "tone", min = -1, max = 1, step = 0.01, def = 0, shader = "brightness", desc = "brightness_desc" },
{ key = "highlights", group = "tone", min = -1, max = 1, step = 0.01, def = 0, shader = "highlights", desc = "highlights_desc" },
{ key = "shadows", group = "tone", min = -1, max = 1, step = 0.01, def = 0, shader = "shadows", desc = "shadows_desc" },
{ key = "whites", group = "tone", min = -1, max = 1, step = 0.01, def = 0, shader = "whites", desc = "whites_desc" },
{ key = "blacks", group = "tone", min = -1, max = 1, step = 0.01, def = 0, shader = "blacks", desc = "blacks_desc" },
-- Ausbleichen
{ key = "highlight_fade", group = "fade", min = 0, max = 1, step = 0.01, def = 0, shader = "highlight_fade", desc = "highlight_fade_desc" },
{ key = "shadow_fade", group = "fade", min = 0, max = 1, step = 0.01, def = 0, shader = "shadow_fade", desc = "shadow_fade_desc" },
{ key = "black_lift", group = "fade", min = 0, max = 1, step = 0.01, def = 0, shader = "black_lift", desc = "black_lift_desc" },
-- Weißabgleich
{ key = "temperature", group = "wb", min = -1, max = 1, step = 0.01, def = 0, shader = "temperature", desc = "temperature_desc" },
{ key = "tint", group = "wb", min = -1, max = 1, step = 0.01, def = 0, shader = "tint", desc = "tint_desc" },
-- Farbe
{ key = "saturation", group = "color", min = -1, max = 1, step = 0.01, def = 0, shader = "saturation", desc = "saturation_desc" },
{ key = "vibrance", group = "color", min = -1, max = 1, step = 0.01, def = 0, shader = "vibrance", desc = "vibrance_desc" },
-- Farbräder
{ key = "shadows_color_r", group = "wheels", min = -1, max = 1, step = 0.01, def = 0, desc = "wheels_desc" },
{ key = "shadows_color_g", group = "wheels", min = -1, max = 1, step = 0.01, def = 0, desc = "wheels_desc" },
{ key = "shadows_color_b", group = "wheels", min = -1, max = 1, step = 0.01, def = 0, desc = "wheels_desc" },
{ key = "midtones_color_r", group = "wheels", min = -1, max = 1, step = 0.01, def = 0, desc = "wheels_desc" },
{ key = "midtones_color_g", group = "wheels", min = -1, max = 1, step = 0.01, def = 0, desc = "wheels_desc" },
{ key = "midtones_color_b", group = "wheels", min = -1, max = 1, step = 0.01, def = 0, desc = "wheels_desc" },
{ key = "highlights_color_r", group = "wheels", min = -1, max = 1, step = 0.01, def = 0, desc = "wheels_desc" },
{ key = "highlights_color_g", group = "wheels", min = -1, max = 1, step = 0.01, def = 0, desc = "wheels_desc" },
{ key = "highlights_color_b", group = "wheels", min = -1, max = 1, step = 0.01, def = 0, desc = "wheels_desc" },
-- Split-Toning
{ key = "ss_r", group = "split", min = -1, max = 1, step = 0.01, def = 0, desc = "split_color_desc" },
{ key = "ss_g", group = "split", min = -1, max = 1, step = 0.01, def = 0, desc = "split_color_desc" },
{ key = "ss_b", group = "split", min = -1, max = 1, step = 0.01, def = 0, desc = "split_color_desc" },
{ key = "ss_sat", group = "split", min = 0, max = 1, step = 0.01, def = 0, desc = "split_sat_desc" },
{ key = "sh_r", group = "split", min = -1, max = 1, step = 0.01, def = 0, desc = "split_color_desc" },
{ key = "sh_g", group = "split", min = -1, max = 1, step = 0.01, def = 0, desc = "split_color_desc" },
{ key = "sh_b", group = "split", min = -1, max = 1, step = 0.01, def = 0, desc = "split_color_desc" },
{ key = "sh_sat", group = "split", min = 0, max = 1, step = 0.01, def = 0, desc = "split_sat_desc" },
-- Kreative Effekte
{ key = "sharpen", group = "fx", min = 0, max = 1, step = 0.01, def = 0, shader = "sharpen_amount", desc = "sharpen_desc" },
{ key = "bloom", group = "fx", min = 0, max = 1, step = 0.01, def = 0, shader = "bloom_intensity", desc = "bloom_desc" },
{ key = "halation", group = "fx", min = 0, max = 1, step = 0.01, def = 0, shader = "halation", desc = "halation_desc" },
{ key = "grain_amount", group = "fx", min = 0, max = 1, step = 0.01, def = 0, shader = "grain_amount", desc = "grain_amount_desc" },
{ key = "grain_size", group = "fx", min = 1, max = 8, step = 0.1, def = 2, shader = "grain_size", desc = "grain_size_desc" },
-- Vignette
{ key = "vignette_amount", group = "vignette", min = 0, max = 1, step = 0.01, def = 0, shader = "vignette_amount", desc = "vignette_amount_desc" },
{ key = "vignette_radius", group = "vignette", min = 0, max = 1, step = 0.01, def = 0.75, shader = "vignette_radius", desc = "vignette_radius_desc" },
{ key = "vignette_feather", group = "vignette", min = 0, max = 1, step = 0.01, def = 0.5, shader = "vignette_feather", desc = "vignette_feather_desc" },
{ key = "vignette_shape", group = "vignette", min = 0, max = 1, step = 0.01, def = 0, shader = "vignette_shape", desc = "vignette_shape_desc" },
-- Maske (Position/Größe; enable_mask und mask_shape sind keine Slider)
{ key = "mask_x", group = "mask", min = 0, max = 1, step = 0.01, def = 0.5, desc = "mask_pos_desc" },
{ key = "mask_y", group = "mask", min = 0, max = 1, step = 0.01, def = 0.5, desc = "mask_pos_desc" },
{ key = "mask_width", group = "mask", min = 0, max = 1, step = 0.01, def = 0.25, desc = "mask_size_desc" },
{ key = "mask_height", group = "mask", min = 0, max = 1, step = 0.01, def = 0.25, desc = "mask_size_desc" },
{ key = "mask_feather", group = "mask", min = 0, max = 0.5, step = 0.01, def = 0.1, shader = "mask_feather", desc = "mask_feather_desc" },
-- LUT
{ key = "lut_strength", group = "lut", min = 0, max = 1, step = 0.01, def = 1, shader = "lut_strength", desc = "lut_strength_desc" },
}
local PARAM_BY_KEY = {}
for _, p in ipairs(PARAMS) do PARAM_BY_KEY[p.key] = p end
-- Vektor-Uniforms, die aus mehreren Settings zusammengesetzt werden
local VEC4_PARAMS = {
{ shader = "shadows_color", keys = { "shadows_color_r", "shadows_color_g", "shadows_color_b" } },
{ shader = "midtones_color", keys = { "midtones_color_r", "midtones_color_g", "midtones_color_b" } },
{ shader = "highlights_color", keys = { "highlights_color_r", "highlights_color_g", "highlights_color_b" } },
{ shader = "split_shadow", keys = { "ss_r", "ss_g", "ss_b", "ss_sat" } },
{ shader = "split_highlight", keys = { "sh_r", "sh_g", "sh_b", "sh_sat" } },
}
-- Typen der Shader-Parameter, die nicht aus PARAMS hervorgehen (für die Initialisierung)
local VEC2_PARAM_NAMES = { mask_pos = true, mask_size = true }
local VEC4_PARAM_NAMES = { buffer_size = true, lut_info = true }
for _, vec in ipairs(VEC4_PARAMS) do VEC4_PARAM_NAMES[vec.shader] = true end
local TEXTURE_PARAM_NAMES = { lut_tex = true }
-- Schlüssel, die Presets und "Zurücksetzen" anfassen (Maske und LUT bleiben unberührt)
local function is_look_key(key)
local p = PARAM_BY_KEY[key]
return p ~= nil and p.group ~= "mask" and p.group ~= "lut"
end
-- Alte Versionen haben interne Felder in den Szenen-Settings abgelegt. Diese
-- Schlüssel werden beim ersten Update einer alten Szene entfernt.
local LEGACY_JUNK_KEYS = {
"dirty", "width", "height", "invalid", "lut_bound", "preview_width", "preview_height",
"last_seed_ts", "last_time_seed", "last_grain_amount", "last_grain_size", "last_vignette_shape",
"shadows_red", "shadows_green", "shadows_blue", "shadows_sat",
"highlights_red", "highlights_green", "highlights_blue", "highlights_sat",
}
for _, p in ipairs(PARAMS) do table.insert(LEGACY_JUNK_KEYS, "last_" .. p.key) end
-- ---------------------------------------------------------------------------
-- Presets (Werte beziehen sich auf echte Settings-Schlüssel)
-- ---------------------------------------------------------------------------
local PRESET_CATEGORIES = {
{ id = "cat_basic", presets = {
{ id = "neutral", v = {} },
{ id = "warm", v = { temperature = 0.2, tint = 0.05 } },
{ id = "cool", v = { temperature = -0.2, tint = -0.05 } },
{ id = "bright", v = { exposure = 0.1, highlights = 0.05, shadows = 0.1 } },
{ id = "dark", v = { exposure = -0.1, shadows = -0.05, blacks = -0.05 } },
{ id = "contrast_boost", v = { contrast = 0.15, highlights = 0.1, shadows = -0.1, blacks = -0.05 } },
{ id = "contrast_reduce", v = { contrast = -0.1, highlights = -0.05, shadows = 0.05 } },
} },
{ id = "cat_style", presets = {
{ id = "cinematic", v = { contrast = 0.2, highlights = -0.1, shadows = 0.05, saturation = -0.1, midtones_color_b = 0.05, shadows_color_b = 0.1, vignette_amount = 0.15, vignette_feather = 0.8 } },
{ id = "filmic", v = { contrast = 0.15, highlights = -0.05, shadows = 0.05, saturation = -0.05, vignette_amount = 0.1, vignette_feather = 0.7 } },
{ id = "dramatic", v = { contrast = 0.25, highlights = -0.1, shadows = -0.15, blacks = -0.1, saturation = 0.1, vignette_amount = 0.25, vignette_radius = 0.7 } },
{ id = "vibrant", v = { saturation = 0.2, vibrance = 0.15, contrast = 0.1, highlights = 0.05 } },
{ id = "muted", v = { saturation = -0.15, vibrance = -0.1, contrast = -0.05 } },
{ id = "warm_contrast", v = { temperature = 0.15, contrast = 0.15, highlights = 0.05, shadows = -0.05 } },
{ id = "cool_contrast", v = { temperature = -0.15, contrast = 0.15, highlights = 0.05, shadows = -0.05 } },
{ id = "bleach_bypass", v = { saturation = -0.45, contrast = 0.3, highlights = -0.1, blacks = -0.1, highlight_fade = 0.1 } },
{ id = "faded_shadows", v = { black_lift = 0.35, contrast = -0.05, saturation = -0.1, shadow_fade = 0.1 } },
{ id = "cross_process", v = { contrast = 0.15, saturation = 0.15, shadows_color_g = 0.08, shadows_color_b = 0.15, highlights_color_r = 0.1, highlights_color_g = 0.1, highlights_color_b = -0.1 } },
{ id = "crisp_clarity", v = { contrast = 0.2, highlights = -0.05, shadows = -0.05, blacks = -0.05, vibrance = 0.1, sharpen = 0.4, vignette_amount = 0.1 } },
} },
{ id = "cat_mood", presets = {
{ id = "highlights_warm", v = { highlights_color_r = 0.1, highlights_color_g = 0.05 } },
{ id = "shadows_blue", v = { shadows_color_b = 0.15 } },
{ id = "shadows_green", v = { shadows_color_g = 0.15 } },
{ id = "sunset", v = { temperature = 0.2, highlights_color_r = 0.15, highlights_color_g = 0.05, shadows_color_b = 0.1 } },
{ id = "moonlight", v = { temperature = -0.3, exposure = -0.1, highlights_color_b = 0.1, midtones_color_b = 0.05, shadows = -0.1, contrast = 0.1 } },
{ id = "vivid_warm", v = { temperature = 0.15, saturation = 0.15, vibrance = 0.1, contrast = 0.1 } },
{ id = "moody_blue", v = { temperature = -0.25, saturation = -0.2, contrast = 0.15, shadows_color_b = 0.15, midtones_color_b = 0.08, vignette_amount = 0.2 } },
{ id = "warm_portrait", v = { temperature = 0.12, tint = 0.03, contrast = -0.05, highlights = -0.08, shadows = 0.08, vibrance = 0.12, saturation = -0.05, highlights_color_r = 0.05, midtones_color_r = 0.04 } },
{ id = "day_for_night", v = { exposure = -0.6, contrast = 0.2, saturation = -0.35, temperature = -0.4, shadows_color_b = 0.2, midtones_color_b = 0.1, highlights = -0.3, vignette_amount = 0.3 } },
{ id = "night_enhancer", v = { exposure = 0.3, shadows = 0.4, black_lift = 0.15, contrast = 0.05, blacks = 0.1, vibrance = 0.1, sharpen = 0.1 } },
{ id = "forest_green", v = { temperature = -0.05, midtones_color_g = 0.1, shadows_color_g = 0.06, shadows_color_b = 0.04, highlights_color_g = 0.04, saturation = 0.05, vibrance = 0.1 } },
{ id = "desert_heat", v = { temperature = 0.35, tint = 0.05, contrast = 0.1, highlights_color_r = 0.1, highlights_color_g = 0.06, midtones_color_r = 0.06, saturation = 0.05, whites = 0.05 } },
{ id = "arctic_chill", v = { temperature = -0.35, tint = -0.03, exposure = 0.05, highlights_color_b = 0.1, midtones_color_b = 0.06, saturation = -0.15, whites = 0.1 } },
{ id = "horror_atmosphere", v = { temperature = -0.2, saturation = -0.3, contrast = 0.2, highlights = -0.1, shadows = -0.2, blacks = -0.15, vignette_amount = 0.4, vignette_feather = 0.5, ss_b = 0.15, ss_sat = 0.2, sharpen = 0.15 } },
} },
{ id = "cat_era", presets = {
{ id = "vintage", v = { temperature = 0.15, contrast = 0.1, saturation = -0.15, shadows_color_r = 0.05, shadows_color_b = -0.1, highlights_color_r = 0.1, highlights_color_b = -0.05, vignette_amount = 0.2, grain_amount = 0.15, grain_size = 2.5 } },
{ id = "retro_film", v = { temperature = 0.1, saturation = -0.2, contrast = 0.15, highlights = -0.1, shadows = 0.05, vignette_amount = 0.25, halation = 0.4, sh_r = 0.15, sh_sat = 0.3, grain_amount = 0.2, grain_size = 2.5 } },
{ id = "film_70s", v = { temperature = 0.25, tint = 0.05, contrast = 0.05, saturation = -0.1, highlights_color_r = 0.12, highlights_color_g = 0.08, shadows_color_r = 0.05, shadows_color_g = 0.03, black_lift = 0.15, grain_amount = 0.25, grain_size = 3, vignette_amount = 0.2 } },
{ id = "retro_80s", v = { saturation = 0.25, vibrance = 0.1, contrast = 0.05, highlights = 0.05, ss_r = 0.1, ss_b = 0.1, ss_sat = 0.25, sh_r = 0.15, sh_g = 0.05, sh_sat = 0.25, bloom = 0.2 } },
{ id = "vhs_90s", v = { saturation = 0.05, contrast = -0.05, bloom = 0.15, halation = 0.3, grain_amount = 0.35, grain_size = 3.5, shadows_color_b = 0.05, highlights_color_r = 0.05, black_lift = 0.1 } },
{ id = "analog_vhs", v = { saturation = 0.1, contrast = 0.1, highlights = -0.1, shadows = -0.05, halation = 0.5, sh_r = 0.1, sh_sat = 0.2, ss_b = 0.1, ss_sat = 0.2, grain_amount = 0.3, grain_size = 3 } },
{ id = "sepia", v = { saturation = -0.8, temperature = 0.3, contrast = 0.05, highlights_color_r = 0.1, highlights_color_g = 0.05, shadows_color_r = 0.1, shadows_color_g = 0.05 } },
{ id = "bw", v = { saturation = -1, contrast = 0.1, highlights = 0.05, shadows = -0.05, blacks = -0.05 } },
{ id = "bw_high_contrast", v = { saturation = -1, contrast = 0.2, highlights = 0.1, shadows = -0.1, blacks = -0.1 } },
} },
{ id = "cat_creative", presets = {
{ id = "neo_noir", v = { saturation = -0.9, contrast = 0.25, blacks = -0.15, vignette_amount = 0.4, vignette_feather = 0.6, ss_b = 0.2, ss_sat = 0.3, sharpen = 0.2 } },
{ id = "cyberpunk", v = { contrast = 0.2, blacks = -0.1, vibrance = 0.3, sh_b = 0.3, sh_sat = 0.5, ss_r = 0.2, ss_sat = 0.4, bloom = 0.4, halation = 0.2 } },
{ id = "teal_orange", v = { contrast = 0.2, vibrance = 0.1, sh_r = 0.25, sh_g = 0.1, sh_sat = 0.4, ss_b = 0.25, ss_g = 0.1, ss_sat = 0.4, sharpen = 0.15 } },
{ id = "dreamy_bloom", v = { contrast = -0.05, highlights = 0.1, shadows = 0.1, saturation = 0.05, bloom = 0.5, halation = 0.3, sh_r = 0.1, sh_g = 0.05, sh_sat = 0.2 } },
{ id = "pastel_dreams", v = { contrast = -0.1, highlights = 0.1, shadows = 0.1, saturation = -0.1, vibrance = 0.1, sh_r = 0.1, sh_b = 0.1, sh_sat = 0.3, ss_b = 0.15, ss_sat = 0.3, bloom = 0.2 } },
{ id = "game_stream", v = { contrast = 0.15, highlights = -0.05, shadows = 0.05, vibrance = 0.2, saturation = 0.1, sharpen = 0.3, vignette_amount = 0.15, vignette_feather = 0.7 } },
} },
}
local PRESET_BY_ID = {}
local PRESET_COUNT = 0
for _, cat in ipairs(PRESET_CATEGORIES) do
for _, preset in ipairs(cat.presets) do
PRESET_BY_ID[preset.id] = preset
PRESET_COUNT = PRESET_COUNT + 1
end
end
-- ---------------------------------------------------------------------------
-- Shader (HLSL-Dialekt; libobs übersetzt für OpenGL/Metal)
-- Die nicht-räumlichen Stufen in grade_color werden vom LUT-Export 1:1 in Lua
-- nachgebildet (siehe grade_pixel weiter unten).
-- ---------------------------------------------------------------------------
local shader_code = [[
uniform float4x4 ViewProj;
uniform texture2d image;
uniform float4 buffer_size; // x = width, y = height, z = 1/width, w = 1/height
// --- tone -------------------------------------------------------------------
uniform float exposure;
uniform float contrast;
uniform float brightness;
uniform float highlights;
uniform float shadows;
uniform float whites;
uniform float blacks;
uniform float black_lift;
uniform float highlight_fade;
uniform float shadow_fade;
// --- white balance / colour ---------------------------------------------------
uniform float temperature;
uniform float tint;
uniform float saturation;
uniform float vibrance;
// --- colour wheels (rgb offsets per tonal range, w unused) --------------------
uniform float4 shadows_color;
uniform float4 midtones_color;
uniform float4 highlights_color;
// --- split toning (rgb offset + strength in w) --------------------------------
uniform float4 split_shadow;
uniform float4 split_highlight;
// --- creative fx --------------------------------------------------------------
uniform float sharpen_amount;
uniform float bloom_intensity;
uniform float halation;
uniform float grain_amount;
uniform float grain_size;
uniform float grain_time;
// --- vignette -----------------------------------------------------------------
uniform float vignette_amount;
uniform float vignette_radius;
uniform float vignette_feather;
uniform float vignette_shape;
// --- mask ---------------------------------------------------------------------
uniform float enable_mask;
uniform float2 mask_pos;
uniform float2 mask_size;
uniform float mask_feather;
uniform float mask_roundness;
// --- 3D LUT stored as a 2D atlas of size*size tiles -----------------------------
uniform float lut_strength;
uniform float4 lut_info; // x = cube size, y = tiles per row, z = 1/tex_w, w = 1/tex_h
uniform texture2d lut_tex;
sampler_state imageSampler {
Filter = Linear;
AddressU = Clamp;
AddressV = Clamp;
};
sampler_state lutSampler {
Filter = Linear;
AddressU = Clamp;
AddressV = Clamp;
};
struct VertDataIn {
float4 pos : POSITION;
float2 uv : TEXCOORD0;
};
struct VertDataOut {
float4 pos : POSITION;
float2 uv : TEXCOORD0;
};
VertDataOut VSDefault(VertDataIn v_in)
{
VertDataOut vert_out;
vert_out.pos = mul(float4(v_in.pos.xyz, 1.0), ViewProj);
vert_out.uv = v_in.uv;
return vert_out;
}
float luma_of(float3 c)
{
return dot(c, float3(0.2126, 0.7152, 0.0722));
}
// Non-spatial pipeline (mirrored 1:1 by the Lua LUT exporter)
float3 grade_color(float3 c)
{
// 1. exposure
c *= pow(2.0, exposure);
// 2. tonal ranges, weighted by luminance so hues do not drift
float l = luma_of(c);
float hw = smoothstep(0.5, 1.0, l);
float sw = 1.0 - smoothstep(0.0, 0.5, l);
float ww = smoothstep(0.7, 1.0, l);
float bw = 1.0 - smoothstep(0.0, 0.3, l);
float lw = 1.0 - smoothstep(0.0, 0.4, l);
c *= (1.0 + highlights * hw) * (1.0 + shadows * sw);
c += whites * 0.5 * ww + blacks * 0.5 * bw + black_lift * 0.5 * lw;
// 3. brightness, 4. contrast (pivot at mid grey)
c += brightness;
c = (c - 0.5) * (1.0 + contrast) + 0.5;
// 5. white balance: + temperature = warmer, + tint = magenta
c += temperature * float3(0.1, 0.0, -0.1) + tint * float3(0.05, -0.1, 0.05);
c = saturate(c);
// 6. fades towards white (film bleach look)
l = luma_of(c);
c = lerp(c, float3(1.0, 1.0, 1.0), smoothstep(0.7, 1.0, l) * highlight_fade);
c = lerp(c, float3(1.0, 1.0, 1.0), (1.0 - smoothstep(0.0, 0.3, l)) * shadow_fade);
// 7. colour wheels
l = luma_of(c);
float w_sh = 1.0 - smoothstep(0.0, 0.5, l);
float w_hi = smoothstep(0.5, 1.0, l);
float w_mid = 1.0 - w_sh - w_hi;
c = saturate(c + shadows_color.rgb * w_sh * 0.5 + midtones_color.rgb * w_mid * 0.3 + highlights_color.rgb * w_hi * 0.5);
// 8. split toning
l = luma_of(c);
float wS = saturate(1.0 - l * 2.0);
float wH = saturate((l - 0.5) * 2.0);
c = lerp(c, saturate(c + split_shadow.rgb), split_shadow.w * wS);
c = lerp(c, saturate(c + split_highlight.rgb), split_highlight.w * wH);
// 9. saturation
float g = luma_of(c);
c = lerp(float3(g, g, g), c, 1.0 + saturation);
// 10. vibrance (boosts low-saturation colours more than saturated ones)
float lum = luma_of(c);
float mx = max(max(c.r, c.g), c.b);
float mn = min(min(c.r, c.g), c.b);
float sat = (mx - mn) / max(mx, 0.0001);
c = lerp(float3(lum, lum, lum), c, 1.0 + vibrance * (1.0 - sat));
return c;
}
// LUT lookup: trilinear via two bilinear fetches in the 2D atlas.
float3 sample_lut(float3 c)
{
float size = lut_info.x;
float tiles = lut_info.y;
c = saturate(c);
float bf = c.b * (size - 1.0);
float b0 = floor(bf);
float b1 = min(b0 + 1.0, size - 1.0);
float f = bf - b0;
float2 rg = c.rg * (size - 1.0) + 0.5;
float2 t0 = float2(b0 - floor(b0 / tiles) * tiles, floor(b0 / tiles));
float2 t1 = float2(b1 - floor(b1 / tiles) * tiles, floor(b1 / tiles));
float2 uv0 = (t0 * size + rg) * lut_info.zw;
float2 uv1 = (t1 * size + rg) * lut_info.zw;
float3 s0 = lut_tex.Sample(lutSampler, uv0).rgb;
float3 s1 = lut_tex.Sample(lutSampler, uv1).rgb;
return lerp(s0, s1, f);
}
// 9-tap glow kernel on the input image, radius scales with resolution.
float3 glow_sample(float2 uv)
{
float2 px = buffer_size.zw * (buffer_size.y / 1080.0) * 6.0;
float2 dg = px * 0.7071;
float3 s = image.Sample(imageSampler, uv).rgb * 2.0;
s += image.Sample(imageSampler, uv + float2( px.x, 0.0)).rgb;
s += image.Sample(imageSampler, uv + float2(-px.x, 0.0)).rgb;
s += image.Sample(imageSampler, uv + float2(0.0, px.y)).rgb;
s += image.Sample(imageSampler, uv + float2(0.0, -px.y)).rgb;
s += image.Sample(imageSampler, uv + dg).rgb;
s += image.Sample(imageSampler, uv - dg).rgb;
s += image.Sample(imageSampler, uv + float2( dg.x, -dg.y)).rgb;
s += image.Sample(imageSampler, uv + float2(-dg.x, dg.y)).rgb;
return s / 10.0;
}
float3 unsharp_mask(float2 uv, float3 base, float amt)
{
float2 px = buffer_size.zw;
float3 blur = (image.Sample(imageSampler, uv + px).rgb +
image.Sample(imageSampler, uv - px).rgb +
image.Sample(imageSampler, uv + float2( px.x, -px.y)).rgb +
image.Sample(imageSampler, uv + float2(-px.x, px.y)).rgb) * 0.25;
return saturate(base + (base - blur) * amt);
}
float hash12(float2 p)
{
float3 p3 = float3(p.x, p.y, p.x) * 0.1031;
p3 = p3 - floor(p3);
p3 += dot(p3, float3(p3.y, p3.z, p3.x) + 33.33);
float h = (p3.x + p3.y) * p3.z;
return h - floor(h);
}
float4 PSDefault(VertDataOut v_in) : TARGET
{
float4 src = image.Sample(imageSampler, v_in.uv);
float3 c = grade_color(src.rgb);
if (bloom_intensity > 0.001 || halation > 0.001) {
float3 glow = glow_sample(v_in.uv);
float3 bright = glow * smoothstep(0.45, 0.9, luma_of(glow));
c += bright * bloom_intensity * 0.8;
c += float3(0.30, 0.10, 0.03) * bright.r * halation;
}
if (sharpen_amount > 0.001) {
c = unsharp_mask(v_in.uv, c, sharpen_amount);
}
if (lut_strength > 0.001) {
c = lerp(c, sample_lut(c), lut_strength);
}
if (vignette_amount > 0.0) {
float2 d = abs(v_in.uv - float2(0.5, 0.5));
float dist = lerp(length(d), sqrt(d.x * d.x * 1.5 + d.y * d.y * 0.5), vignette_shape);
float feather = max(vignette_feather, 0.001);
float vig = 1.0 - smoothstep(vignette_radius - feather, vignette_radius, dist);
c *= lerp(1.0, vig, vignette_amount);
}
if (grain_amount > 0.0) {
float scale = max(grain_size, 1.0) * max(buffer_size.y / 1080.0, 0.5);
float2 cell = floor(v_in.uv * buffer_size.xy / scale);
float n1 = hash12(cell + float2(grain_time * 61.0, grain_time * 37.0));
float n2 = hash12(cell * 1.7 + float2(grain_time * 23.0, 11.0));
float g = n1 + n2 - 1.0;
float lw = 1.0 - smoothstep(0.5, 1.0, luma_of(c));
c += g * grain_amount * 0.15 * (0.4 + 0.6 * lw);
}
if (enable_mask > 0.5) {
float2 size = max(mask_size, float2(0.0001, 0.0001));
float2 rel = (v_in.uv - mask_pos) / size;
float dist = lerp(max(abs(rel.x), abs(rel.y)), length(rel), mask_roundness);
float feather = max(mask_feather, 0.001);
float m = 1.0 - smoothstep(1.0 - feather, 1.0, dist);
c = lerp(src.rgb, c, m);
}
return float4(saturate(c), src.a);
}
technique Draw
{
pass
{
vertex_shader = VSDefault(v_in);
pixel_shader = PSDefault(v_in);
}
}
]]
-- ===== MODULE: png_encoder (dev/lua/png_encoder.lua) ========================
local png_encoder = (function()
--[[
png_encoder.lua - pure Lua 5.1 / LuaJIT PNG encoder for 8/16-bit RGBA images.
Produces a valid, spec-compliant PNG:
- PNG signature
- IHDR (colour type 6 = RGBA, compression 0, filter 0, interlace 0)
- IDAT: a zlib stream (RFC 1950) wrapping "stored" (uncompressed) DEFLATE
blocks (RFC 1951, BTYPE=00), each scanline prefixed with filter byte 0
- IEND
No os/io, no ffi, no C modules - safe to inline into an OBS Lua script.
Uses LuaJIT's `bit` library for CRC-32 (table-driven). `bit` operations
return signed 32-bit Lua numbers; every value that is written out as bytes
or compared/returned as a checksum is renormalised to the unsigned range
[0, 2^32-1] via plain arithmetic (`% 4294967296`) before use, since LuaJIT's
bit ops treat their operand only as a 32-bit *pattern* (band/rshift work
identically either way), but returned/displayed numbers must be unsigned.
]]
local bit_ok, bit = pcall(require, "bit")
if not bit_ok then bit = bit32 or _G.bit end -- fallback, still no globals leak
local band, bxor, rshift = bit.band, bit.bxor, bit.rshift
local M = {}
--------------------------------------------------------------------------
-- byte helpers (plain arithmetic, values are always non-negative here)
--------------------------------------------------------------------------
local function u32be(n)
n = n % 4294967296
local b4 = n % 256; n = (n - b4) / 256
local b3 = n % 256; n = (n - b3) / 256
local b2 = n % 256; n = (n - b2) / 256
local b1 = n % 256
return string.char(b1, b2, b3, b4)
end
local function u16le(n)
local b1 = n % 256
local b2 = (n - b1) / 256
return string.char(b1, b2)
end
--------------------------------------------------------------------------
-- CRC-32 (table-driven, LuaJIT bit library)
--------------------------------------------------------------------------
local CRC_POLY = 0xEDB88320
local crc_table = {}
do
for n = 0, 255 do
local c = n
for _ = 1, 8 do
if band(c, 1) ~= 0 then
c = bxor(CRC_POLY, rshift(c, 1))
else
c = rshift(c, 1)
end
end
crc_table[n] = c
end
end
--- CRC-32 (IEEE 802.3) of `str`. Returns an unsigned integer 0..2^32-1.
function M.crc32(str)
local crc = -1 -- 0xFFFFFFFF as a 32-bit bit pattern
local byte = string.byte
local t = crc_table
local len = #str
for i = 1, len do
crc = bxor(t[band(bxor(crc, byte(str, i)), 0xFF)], rshift(crc, 8))
end
crc = bxor(crc, -1)
if crc < 0 then crc = crc + 4294967296 end
return crc
end
--------------------------------------------------------------------------
-- Adler-32 (RFC 1950), plain arithmetic - no bit ops needed
--------------------------------------------------------------------------
local ADLER_MOD = 65521
local NMAX = 5552 -- largest n such that 255*n*(n+1)/2 + (n+1)*(65520) <= 2^32-1
--- Adler-32 checksum of `str`. Returns an unsigned integer 0..2^32-1.
function M.adler32(str)
local a, b = 1, 0
local byte = string.byte
local len = #str
local i = 1
while i <= len do
local blockEnd = i + NMAX - 1
if blockEnd > len then blockEnd = len end
for j = i, blockEnd do
a = a + byte(str, j)
b = b + a
end
a = a % ADLER_MOD
b = b % ADLER_MOD
i = blockEnd + 1
end
return b * 65536 + a
end
--------------------------------------------------------------------------
-- DEFLATE (stored blocks only, RFC 1951 section 3.2.4)
--------------------------------------------------------------------------
local STORED_MAX = 65535
-- Appends one or more "stored" DEFLATE blocks encoding `data` to `parts`.
local function append_stored_blocks(parts, pi, data)
local len = #data
if len == 0 then
parts[pi + 1] = string.char(1) -- BFINAL=1, BTYPE=00, rest padding zero bits
parts[pi + 2] = u16le(0)
parts[pi + 3] = u16le(0xFFFF)
return pi + 3
end
local pos = 1
while pos <= len do
local remaining = len - pos + 1
local blen = remaining > STORED_MAX and STORED_MAX or remaining
local is_last = (pos + blen - 1 == len)
parts[pi + 1] = string.char(is_last and 1 or 0)
parts[pi + 2] = u16le(blen)
parts[pi + 3] = u16le(0xFFFF - blen) -- one's complement of a 16-bit value
parts[pi + 4] = string.sub(data, pos, pos + blen - 1)
pi = pi + 4
pos = pos + blen
end
return pi
end
--------------------------------------------------------------------------
-- PNG chunk assembly
--------------------------------------------------------------------------
local PNG_SIGNATURE = string.char(137, 80, 78, 71, 13, 10, 26, 10)
local function write_chunk(chunk_type, data)
local crc_input = chunk_type .. data
return u32be(#data) .. crc_input .. u32be(M.crc32(crc_input))
end
--------------------------------------------------------------------------
-- Public API
--------------------------------------------------------------------------
--- Encode an RGBA raster as a PNG byte string.
-- @param width image width in pixels (integer > 0)
-- @param height image height in pixels (integer > 0)
-- @param rows 1-based array of `height` scanline strings; each scanline
-- holds raw RGBA samples in PNG byte order (8-bit: 4
-- bytes/pixel; 16-bit: 8 bytes/pixel, big-endian samples)
-- @param bitdepth 8 or 16
-- @return the encoded PNG file contents as a Lua string
function M.encode_rgba(width, height, rows, bitdepth)
if type(width) ~= "number" or width < 1 or width % 1 ~= 0 then
error("png_encoder: width must be a positive integer", 2)
end
if type(height) ~= "number" or height < 1 or height % 1 ~= 0 then
error("png_encoder: height must be a positive integer", 2)
end
if bitdepth ~= 8 and bitdepth ~= 16 then
error("png_encoder: bitdepth must be 8 or 16, got " .. tostring(bitdepth), 2)
end
if type(rows) ~= "table" then
error("png_encoder: rows must be a table of scanline strings", 2)
end
if #rows ~= height then
error(("png_encoder: rows has %d entries, expected height=%d"):format(#rows, height), 2)
end
local bytes_per_pixel = 4 * (bitdepth / 8)
local expected_row_len = width * bytes_per_pixel
-- Build the zlib "uncompressed data": each scanline prefixed with a
-- filter-type byte (0 = None), concatenated for every row.
local raw_parts = {}
local rp = 0
for y = 1, height do
local row = rows[y]
if type(row) ~= "string" or #row ~= expected_row_len then
error(("png_encoder: row %d has length %s, expected %d (width=%d * %d bytes/px)")
:format(y, type(row) == "string" and tostring(#row) or type(row), expected_row_len, width, bytes_per_pixel), 2)
end
rp = rp + 1; raw_parts[rp] = "\0"
rp = rp + 1; raw_parts[rp] = row
end
local raw_data = table.concat(raw_parts)
local adler = M.adler32(raw_data)
-- zlib stream: 2-byte header, stored DEFLATE blocks, 4-byte Adler-32 (BE)
local zlib_parts = {}
zlib_parts[1] = string.char(0x78, 0x01)
local zp = append_stored_blocks(zlib_parts, 1, raw_data)
zlib_parts[zp + 1] = u32be(adler)
local zlib_stream = table.concat(zlib_parts)
local ihdr = u32be(width) .. u32be(height) .. string.char(bitdepth, 6, 0, 0, 0)
return table.concat({
PNG_SIGNATURE,
write_chunk("IHDR", ihdr),
write_chunk("IDAT", zlib_stream),
write_chunk("IEND", ""),
})
end
return M
end)()
-- ===== MODULE: cube_lut (dev/lua/cube_lut.lua) ==============================
local cube_lut = (function()
-- cube_lut.lua
-- Utilities for 3D LUTs in Adobe/Resolve .cube format and for the 2D atlas
-- texture layout consumed by the Lumetric Corrector shader (see
-- dev/shader_spec.effect, function sample_lut). Pure Lua 5.1 / LuaJIT; the
-- only non-core dependency is the LuaJIT-builtin `bit` library (not ffi).
-- LuaJIT (OBS) provides `bit`; guard anyway so a plain Lua 5.2+ host with bit32 still loads.
local bit_ok, bit = pcall(require, "bit")
if not bit_ok then bit = bit32 or _G.bit end
local bxor = bit and bit.bxor or function(a, b)
-- portable fallback (slow, only used when no bit library exists)
local r, m = 0, 1
while a > 0 or b > 0 do
local x, y = a % 2, b % 2
if x ~= y then r = r + m end
a, b, m = (a - x) / 2, (b - y) / 2, m * 2
end
return r
end
local floor = math.floor
local M = {}
-- ---------------------------------------------------------------------------
-- .cube parsing
-- ---------------------------------------------------------------------------
--- Parse an Adobe/Resolve .cube 3D LUT.
-- @param text full file contents as a string
-- @return lut = { size, data, domain_min, domain_max, title } or nil, errmsg
function M.parse_cube(text)
if type(text) ~= "string" then
return nil, "cube text must be a string"
end
-- Normalise line endings, then strip '#' comments (to end of line, anywhere).
text = text:gsub("\r\n", "\n"):gsub("\r", "\n")
text = text:gsub("#[^\n]*", "")
local title
text = text:gsub('TITLE%s+"([^"\n]*)"', function(t)
title = t
return ""
end)
if text:find("LUT_1D_SIZE") then
return nil, "LUT_1D_SIZE files are not supported (3D LUTs only)"
end
local size_str
text = text:gsub("LUT_3D_SIZE%s+(%d+)", function(s)
size_str = s
return ""
end)
if not size_str then
return nil, "missing LUT_3D_SIZE"
end
local size = tonumber(size_str)
if not size or size < 2 or size > 256 then
return nil, "LUT_3D_SIZE must be an integer between 2 and 256"
end
local domain_min = { 0.0, 0.0, 0.0 }
local domain_max = { 1.0, 1.0, 1.0 }
-- Resolve/IRIDAS variant: LUT_3D_INPUT_RANGE lo hi (applies to all channels)
text = text:gsub("LUT_3D_INPUT_RANGE%s+(%S+)%s+(%S+)", function(lo, hi)
local l, h = tonumber(lo), tonumber(hi)
if l and h then
domain_min = { l, l, l }
domain_max = { h, h, h }
end
return ""
end)
text = text:gsub("DOMAIN_MIN%s+(%S+)%s+(%S+)%s+(%S+)", function(a, b, c)
domain_min = { tonumber(a) or 0.0, tonumber(b) or 0.0, tonumber(c) or 0.0 }
return ""
end)
text = text:gsub("DOMAIN_MAX%s+(%S+)%s+(%S+)%s+(%S+)", function(a, b, c)
domain_max = { tonumber(a) or 1.0, tonumber(b) or 1.0, tonumber(c) or 1.0 }
return ""
end)
-- Remaining text is pure numeric data (plus whitespace/blank lines).
-- Single gmatch pass over the whole string, no per-line table churn.
local expected = size * size * size * 3
local data = {}
local n = 0
for tok in text:gmatch("%S+") do
local v = tonumber(tok)
if not v then
return nil, ("non-numeric token in LUT data: '%s'"):format(tok)
end
n = n + 1
if n > expected then
return nil, ("too many data values (expected %d)"):format(expected)
end
data[n] = v
end
if n < expected then
return nil, ("too few data values (expected %d, got %d)"):format(expected, n)
end
return {
size = size,
data = data,
domain_min = domain_min,
domain_max = domain_max,
title = title,
}
end
-- ---------------------------------------------------------------------------
-- shared indexing helper (.cube data order: r fastest, then g, then b)
-- ---------------------------------------------------------------------------
local function get_rgb(lut, r, g, b)
local n = lut.size
local base = (r + g * n + b * n * n) * 3
local d = lut.data
return d[base + 1], d[base + 2], d[base + 3]
end
-- ---------------------------------------------------------------------------
-- atlas (strip layout) for the shader
-- ---------------------------------------------------------------------------
local function quantize(v, maxv)
local q = floor(v * maxv + 0.5)
if q < 0 then q = 0 elseif q > maxv then q = maxv end
return q
end
--- Build strip-layout atlas rows (width = N*N, height = N, T = N tiles per
--- row) ready for png_encoder.encode_rgba. 8-bit -> 4 bytes/pixel RGBA,
--- 16-bit -> 8 bytes/pixel big-endian RGBA. Alpha is always fully opaque
--- (the max representable value for the bit depth).
function M.atlas_rows(lut, bitdepth)
local n = lut.size
local n2 = n * n
local data = lut.data
local dmin, dmax = lut.domain_min, lut.domain_max
local den1 = dmax[1] - dmin[1]
local den2 = dmax[2] - dmin[2]
local den3 = dmax[3] - dmin[3]
if den1 == 0 then den1 = 1 end
if den2 == 0 then den2 = 1 end
if den3 == 0 then den3 = 1 end
local width, height = n2, n
local is16 = (bitdepth == 16)
local rows = {}
local char = string.char
for gy = 0, height - 1 do
local parts = {}
for x = 0, width - 1 do
local bx = floor(x / n)
local rx = x - bx * n
local base = (rx + gy * n + bx * n2) * 3
local rv = (data[base + 1] - dmin[1]) / den1
local gv = (data[base + 2] - dmin[2]) / den2
local bv = (data[base + 3] - dmin[3]) / den3
if rv < 0 then rv = 0 elseif rv > 1 then rv = 1 end
if gv < 0 then gv = 0 elseif gv > 1 then gv = 1 end
if bv < 0 then bv = 0 elseif bv > 1 then bv = 1 end
if is16 then
local rq, gq, bq = quantize(rv, 65535), quantize(gv, 65535), quantize(bv, 65535)
parts[x + 1] = char(
floor(rq / 256), rq % 256,
floor(gq / 256), gq % 256,
floor(bq / 256), bq % 256,
255, 255)
else
parts[x + 1] = char(quantize(rv, 255), quantize(gv, 255), quantize(bv, 255), 255)
end
end
rows[gy + 1] = table.concat(parts)
end
return rows, width, height
end
-- ---------------------------------------------------------------------------
-- atlas layout detection (strip or square-grid) from texture dimensions
-- ---------------------------------------------------------------------------
--- Detect atlas layout from texture pixel dimensions.
-- @return size, tiles or nil, errmsg
function M.layout_from_dims(w, h)
if type(w) ~= "number" or type(h) ~= "number" then
return nil, "width/height must be numbers"
end
w, h = floor(w), floor(h)
if w < 1 or h < 1 then
return nil, "invalid dimensions"
end
-- strip: width == height*height, T == size == h
if h >= 2 and w == h * h then
return h, h
end
-- square grid: width == height, T tiles per row/col, size*size*size == w*h
if w == h then
local n = floor((w * h) ^ (1 / 3) + 0.5)
if n >= 2 and n * n * n == w * h and w % n == 0 then
return n, w / n
end
end
return nil, "dimensions do not match a known atlas layout"
end
-- ---------------------------------------------------------------------------
-- trilinear sampling (mirrors sample_lut in shader_spec.effect)
-- ---------------------------------------------------------------------------
local function clamp01(v)
if v < 0 then return 0 elseif v > 1 then return 1 end
return v
end
local function lerp(a, b, t)
return a + (b - a) * t
end
--- Trilinear-interpolate the LUT at (r, g, b), inputs clamped to 0..1.
--- Returns the raw (unclamped) interpolated LUT output.
function M.sample(lut, r, g, b)
local n = lut.size
r, g, b = clamp01(r), clamp01(g), clamp01(b)
local rf = r * (n - 1)
local r0 = floor(rf)
local r1 = r0 + 1; if r1 > n - 1 then r1 = n - 1 end
local fr = rf - r0
local gf = g * (n - 1)
local g0 = floor(gf)
local g1 = g0 + 1; if g1 > n - 1 then g1 = n - 1 end
local fg = gf - g0
local bf = b * (n - 1)
local b0 = floor(bf)
local b1 = b0 + 1; if b1 > n - 1 then b1 = n - 1 end
local fb = bf - b0
local r000, g000, b000 = get_rgb(lut, r0, g0, b0)
local r100, g100, b100 = get_rgb(lut, r1, g0, b0)
local r010, g010, b010 = get_rgb(lut, r0, g1, b0)
local r110, g110, b110 = get_rgb(lut, r1, g1, b0)
local r001, g001, b001 = get_rgb(lut, r0, g0, b1)
local r101, g101, b101 = get_rgb(lut, r1, g0, b1)
local r011, g011, b011 = get_rgb(lut, r0, g1, b1)
local r111, g111, b111 = get_rgb(lut, r1, g1, b1)
-- blue plane b0: bilinear over r then g
local rTop0, gTop0, bTop0 = lerp(r000, r100, fr), lerp(g000, g100, fr), lerp(b000, b100, fr)
local rBot0, gBot0, bBot0 = lerp(r010, r110, fr), lerp(g010, g110, fr), lerp(b010, b110, fr)
local planeR0, planeG0, planeB0 = lerp(rTop0, rBot0, fg), lerp(gTop0, gBot0, fg), lerp(bTop0, bBot0, fg)
-- blue plane b1
local rTop1, gTop1, bTop1 = lerp(r001, r101, fr), lerp(g001, g101, fr), lerp(b001, b101, fr)
local rBot1, gBot1, bBot1 = lerp(r011, r111, fr), lerp(g011, g111, fr), lerp(b011, b111, fr)
local planeR1, planeG1, planeB1 = lerp(rTop1, rBot1, fg), lerp(gTop1, gBot1, fg), lerp(bTop1, bBot1, fg)
-- interpolate between the two blue planes
return lerp(planeR0, planeR1, fb), lerp(planeG0, planeG1, fb), lerp(planeB0, planeB1, fb)
end
-- ---------------------------------------------------------------------------
-- FNV-1a 32-bit hash -> lowercase 8-hex-char string
-- ---------------------------------------------------------------------------
local FNV_C_HI, FNV_C_LO = 0x0100, 0x0193 -- 16777619 (FNV prime) split into 16-bit halves
--- FNV-1a 32-bit hash of str, returned as an 8-char lowercase hex string.
--- Uses only bxor plus plain arithmetic (h stays an unsigned value < 2^32).
function M.fnv1a(str)
local h = 0x811c9dc5
local byte = string.byte
for i = 1, #str do
h = bxor(h, byte(str, i)) % 4294967296
-- 32-bit multiply h * 16777619 mod 2^32, split into 16-bit halves to
-- avoid double-precision overflow.
local h_lo = h % 65536
local h_hi = (h - h_lo) / 65536
local low = h_lo * FNV_C_LO
local mid = (h_lo * FNV_C_HI + h_hi * FNV_C_LO) % 65536
h = (low + mid * 65536) % 4294967296
end
return string.format("%08x", h)
end
--- Fast 32-bit content checksum for cache keys (multiplicative rolling hash,
--- exact in double precision). ~10x faster than fnv1a on multi-MB strings.
function M.checksum(str)
local h = 2166136261
local byte = string.byte
local n = #str
local i = 1
while i + 3 <= n do
local a, b, c, d = byte(str, i, i + 3)
h = (h * 31 + a) % 4294967296
h = (h * 31 + b) % 4294967296
h = (h * 31 + c) % 4294967296
h = (h * 31 + d) % 4294967296
i = i + 4
end
while i <= n do
h = (h * 31 + byte(str, i)) % 4294967296
i = i + 1
end
return string.format("%08x", h)
end
--- Cheap size probe without a full parse: LUT_3D_SIZE value or nil.
function M.peek_size(text)
if type(text) ~= "string" then return nil end
local s = text:match("LUT_3D_SIZE%s+(%d+)")
return s and tonumber(s) or nil
end
return M
end)()
-- ===== MODULE: grade_pixel (dev/lua/grade_pixel.lua) ========================
local grade_pixel = (function()
-- grade_pixel.lua
-- Pure-Lua (5.1 / LuaJIT compatible) port of the NON-spatial colour pipeline defined
-- in dev/shader_spec.effect: grade_color() stages 1-10, followed by the LUT blend
-- stage and the final saturate from PSDefault(). Used to bake the current look into
-- a .cube LUT that matches the on-screen image.
--
-- Skipped on purpose (cannot be baked into a colour-only LUT, see shader_spec.effect
-- header comment): bloom, halation, sharpen (unsharp_mask), vignette, grain, mask.
-- Those all need neighbouring pixels and/or per-pixel screen position (uv), not just
-- the pixel's own colour.
--
-- No globals: require()/dofile() this file, it returns a module table.
local M = {}
-- saturate(x) = clamp(x, 0, 1), matching HLSL's per-component saturate().
local function saturate(x)
if x < 0.0 then return 0.0 end
if x > 1.0 then return 1.0 end
return x
end
-- lerp(a, b, t) = a + (b - a) * t, matching HLSL's lerp().
local function lerp(a, b, t)
return a + (b - a) * t
end
-- smoothstep(e0, e1, x): t = clamp((x-e0)/(e1-e0), 0, 1); return t*t*(3-2t).
local function smoothstep(e0, e1, x)
local t = (x - e0) / (e1 - e0)
if t < 0.0 then
t = 0.0
elseif t > 1.0 then
t = 1.0
end
return t * t * (3.0 - 2.0 * t)
end
M.saturate = saturate
M.lerp = lerp
M.smoothstep = smoothstep
-- luma_of(c) = dot(c, float3(0.2126, 0.7152, 0.0722)) -- Rec.709, used in grade_color.
local function luma709(r, g, b)
return 0.2126 * r + 0.7152 * g + 0.0722 * b
end
-- number parameter, default 0 when nil
local function num(v)
return v or 0.0
end
-- {r, g, b} colour table (index 1..3), default {0,0,0} when nil
local function col3(v)
if v == nil then
return 0.0, 0.0, 0.0
end
return v[1] or 0.0, v[2] or 0.0, v[3] or 0.0
end
-- {r, g, b, w} colour+strength table (index 1..4), default {0,0,0,0} when nil
local function col4(v)
if v == nil then
return 0.0, 0.0, 0.0, 0.0
end
return v[1] or 0.0, v[2] or 0.0, v[3] or 0.0, v[4] or 0.0
end
--- grade_color(p, r, g, b) -> r, g, b
--- Exact port of grade_color() in shader_spec.effect (stages 1-10). Exposed mainly
--- for tests; M.grade() is the public entry point.
local function grade_color(p, r, g, b)
local exposure = num(p.exposure)
local contrast = num(p.contrast)
local brightness = num(p.brightness)
local highlights = num(p.highlights)
local shadows = num(p.shadows)
local whites = num(p.whites)
local blacks = num(p.blacks)
local black_lift = num(p.black_lift)
local highlight_fade = num(p.highlight_fade)
local shadow_fade = num(p.shadow_fade)
local temperature = num(p.temperature)
local tint = num(p.tint)
local saturation = num(p.saturation)
local vibrance = num(p.vibrance)
local sh_r, sh_g, sh_b = col3(p.shadows_color)
local mid_r, mid_g, mid_b = col3(p.midtones_color)
local hi_r, hi_g, hi_b = col3(p.highlights_color)
local ss_r, ss_g, ss_b, ss_w = col4(p.split_shadow)
local sh2_r, sh2_g, sh2_b, sh2_w = col4(p.split_highlight)
-- 1. exposure
local factor = 2.0 ^ exposure
r = r * factor
g = g * factor
b = b * factor
-- 2. tonal ranges, weighted by luminance so hues do not drift
local l = luma709(r, g, b)
local hw = smoothstep(0.5, 1.0, l)
local sw = 1.0 - smoothstep(0.0, 0.5, l)
local ww = smoothstep(0.7, 1.0, l)
local bw = 1.0 - smoothstep(0.0, 0.3, l)
local lw = 1.0 - smoothstep(0.0, 0.4, l)
local mult = (1.0 + highlights * hw) * (1.0 + shadows * sw)
r = r * mult
g = g * mult
b = b * mult
local add = whites * 0.5 * ww + blacks * 0.5 * bw + black_lift * 0.5 * lw
r = r + add
g = g + add
b = b + add
-- 3. brightness, 4. contrast (pivot at mid grey)
r = r + brightness
g = g + brightness
b = b + brightness
local cf = 1.0 + contrast
r = (r - 0.5) * cf + 0.5
g = (g - 0.5) * cf + 0.5
b = (b - 0.5) * cf + 0.5
-- 5. white balance: + temperature = warmer, + tint = magenta
r = r + temperature * 0.1 + tint * 0.05
g = g + temperature * 0.0 + tint * (-0.1)
b = b + temperature * (-0.1) + tint * 0.05
r, g, b = saturate(r), saturate(g), saturate(b)
-- 6. fades towards white (film bleach look) -- both weights are sampled from the
-- SAME luma l (computed once before either lerp), exactly as in the .effect.
l = luma709(r, g, b)
local t_hi = smoothstep(0.7, 1.0, l) * highlight_fade
r, g, b = lerp(r, 1.0, t_hi), lerp(g, 1.0, t_hi), lerp(b, 1.0, t_hi)
local t_sh = (1.0 - smoothstep(0.0, 0.3, l)) * shadow_fade
r, g, b = lerp(r, 1.0, t_sh), lerp(g, 1.0, t_sh), lerp(b, 1.0, t_sh)
-- 7. colour wheels
l = luma709(r, g, b)
local w_sh = 1.0 - smoothstep(0.0, 0.5, l)
local w_hi = smoothstep(0.5, 1.0, l)
local w_mid = 1.0 - w_sh - w_hi
r = saturate(r + sh_r * w_sh * 0.5 + mid_r * w_mid * 0.3 + hi_r * w_hi * 0.5)
g = saturate(g + sh_g * w_sh * 0.5 + mid_g * w_mid * 0.3 + hi_g * w_hi * 0.5)
b = saturate(b + sh_b * w_sh * 0.5 + mid_b * w_mid * 0.3 + hi_b * w_hi * 0.5)
-- 8. split toning (sequential: the highlight lerp reads the shadow-toned colour)
l = luma709(r, g, b)
local wS = saturate(1.0 - l * 2.0)
local wH = saturate((l - 0.5) * 2.0)
do
local tr, tg, tb = saturate(r + ss_r), saturate(g + ss_g), saturate(b + ss_b)
local t = ss_w * wS
r, g, b = lerp(r, tr, t), lerp(g, tg, t), lerp(b, tb, t)
end
do
local tr, tg, tb = saturate(r + sh2_r), saturate(g + sh2_g), saturate(b + sh2_b)
local t = sh2_w * wH
r, g, b = lerp(r, tr, t), lerp(g, tg, t), lerp(b, tb, t)
end
-- 9. saturation
local gl = luma709(r, g, b)
local sf = 1.0 + saturation
r, g, b = lerp(gl, r, sf), lerp(gl, g, sf), lerp(gl, b, sf)
-- 10. vibrance (boosts low-saturation colours more than saturated ones)
local lum = luma709(r, g, b)
local mx = math.max(r, g, b)
local mn = math.min(r, g, b)
local sat = (mx - mn) / math.max(mx, 0.0001)
local vf = 1.0 + vibrance * (1.0 - sat)
r, g, b = lerp(lum, r, vf), lerp(lum, g, vf), lerp(lum, b, vf)
return r, g, b
end
M.grade_color = grade_color
--- M.grade(p, r, g, b) -> r, g, b
--- Full non-spatial pipeline used to bake a LUT: grade_color() stages 1-10, then (if
--- p.lut_strength > 0.001 and both p.lut and p.lut_sample are given) the LUT blend
--- c = lerp(c, lut_sample(lut, saturate(c)), lut_strength)
--- (mirroring sample_lut()'s internal saturate of its input), then the final
--- saturate that PSDefault() applies before writing the pixel. Spatial stages
--- (bloom, halation, sharpen, vignette, grain, mask) are intentionally not
--- reproduced -- see file header.
function M.grade(p, r, g, b)
r, g, b = grade_color(p, r, g, b)
local lut_strength = num(p.lut_strength)
if lut_strength > 0.001 and p.lut and p.lut_sample then
local sr, sg, sb = saturate(r), saturate(g), saturate(b)
local lr, lg, lb = p.lut_sample(p.lut, sr, sg, sb)
r = lerp(r, lr, lut_strength)
g = lerp(g, lg, lut_strength)
b = lerp(b, lb, lut_strength)
end
return saturate(r), saturate(g), saturate(b)
end
return M
end)()
-- ---------------------------------------------------------------------------
-- Datei- und Grafik-Hilfsfunktionen
-- ---------------------------------------------------------------------------
-- Führt eine Grafikoperation aus und gibt den Grafik-Lock auch bei Lua-Fehlern frei.
local function graphics_call(callback)
obs.obs_enter_graphics()
local ok, result = pcall(callback)
obs.obs_leave_graphics()
if not ok then log_warn("Fehler im Grafik-Kontext: " .. tostring(result)) end
return ok, result
end
local function file_extension(path)
return (path or ""):match("%.([%w]+)$")
end
local function read_text_file(path)
local fh = io.open(path, "rb")
if fh then
local text = fh:read("*a")
fh:close()
return text
end
-- io.open kann auf Windows an Nicht-ASCII-Pfaden scheitern; libobs liest UTF-8-Pfade.
if obs.os_quick_read_utf8_file then
local text = obs.os_quick_read_utf8_file(path)
if text and text ~= "" then return text end
end
return nil
end
local function write_binary_file(path, bytes)
local fh, err = io.open(path, "wb")
if fh then
local ok, werr = fh:write(bytes)
fh:close()
if ok then return true end
os.remove(path) -- keine halbe Datei zurücklassen
return false, werr
end
-- io.open scheitert auf Windows an Nicht-ASCII-Pfaden; libobs schreibt UTF-8-Pfade.
-- Die Länge wird explizit übergeben, daher sind NUL-Bytes (PNG) unproblematisch.
if obs.os_quick_write_utf8_file then
local ok = pcall(obs.os_quick_write_utf8_file, path, bytes, #bytes, false)
if ok then return true end
end
return false, err
end
local function write_text_file(path, text)
if obs.os_quick_write_utf8_file then
local ok = obs.os_quick_write_utf8_file(path, text, #text, false)
if ok then return true end
end
return write_binary_file(path, text)
end
local function file_exists(path)
if obs.os_file_exists then return obs.os_file_exists(path) end
local fh = io.open(path, "rb")
if fh then fh:close() return true end
return false
end
local function path_separator()
return package.config:sub(1, 1)
end
-- Cache-Ordner für konvertierte LUTs; ein Treffer wird gemerkt, ein Fehlschlag nicht
-- (sonst würde ein einmaliger IO-Fehler LUTs für die ganze Sitzung blockieren).
local cache_dir_cache = nil
local function lut_cache_dir()
if cache_dir_cache then return cache_dir_cache end
local candidates = {}
if obs.os_get_config_path_ptr then
local ok, dir = pcall(obs.os_get_config_path_ptr, "obs-studio/plugin_config/lumetric_corrector")
if ok and type(dir) == "string" and dir ~= "" then table.insert(candidates, dir) end
end
local tmp = os.getenv("TEMP") or os.getenv("TMP") or os.getenv("TMPDIR") or "/tmp"
table.insert(candidates, tmp .. path_separator() .. "lumetric_corrector")
for _, dir in ipairs(candidates) do
if obs.os_mkdirs then pcall(obs.os_mkdirs, dir) end
local probe = dir .. path_separator() .. ".write_test"
if write_binary_file(probe, "ok") then
os.remove(probe)
cache_dir_cache = dir
return dir
end
end
return nil
end
local function default_documents_path(filename)
local home = os.getenv("USERPROFILE") or os.getenv("HOME") or ""
local sep = path_separator()
local docs = home .. sep .. "Documents"
if obs.os_file_exists and not obs.os_file_exists(docs) then
docs = home -- z. B. Linux ohne XDG-Dokumentenordner
end
return docs .. sep .. filename
end
-- ---------------------------------------------------------------------------
-- LUT laden. Zwei Schritte, damit der UI-Thread nie den Grafik-Kontext braucht:
-- prepare_lut (update, CPU): .cube parsen und als PNG-Atlas im Cache ablegen
-- apply_lut (render, GPU): Textur aus der PNG-Datei erzeugen und binden
-- ---------------------------------------------------------------------------
local function lut_display_name(path)
return path:match("([^/\\]+)$") or path
end
local function set_lut_error(data, path, err)
data.lut_status = "LUT error: " .. tostring(err)
data.lut_error = true
log_error("LUT load failed (" .. tostring(path) .. "): " .. tostring(err))
end
-- Liefert eine Beschreibung der zu ladenden Textur oder nil bei Fehler.
local function prepare_lut(data, path)
data.lut_error = false
if not path or path == "" then
data.lut_status = ""
return nil
end
local ext = (file_extension(path) or ""):lower()
if ext == "png" then
return { path = path, candidates = { { png = path, bits = nil } }, detect_layout = true }
end
if ext ~= "cube" then
set_lut_error(data, path, "unsupported file type ." .. ext .. " (use .cube or .png)")
return nil
end
local text = read_text_file(path)
if not text then
set_lut_error(data, path, "file not readable")
return nil
end
local dir = lut_cache_dir()
if not dir then
set_lut_error(data, path, "no writable cache folder")
return nil
end
-- Cache-Schlüssel über den kompletten Inhalt: eine geänderte Datei erzeugt einen neuen Atlas.
local size = cube_lut.peek_size(text)
local key = cube_lut.checksum(text) .. cube_lut.fnv1a(path)
local candidates = {}
local lut = nil
for _, bits in ipairs({ 16, 8 }) do
local png_path = dir .. path_separator() .. string.format("lut_%s_%s_%dbit.png", key, tostring(size), bits)
if not file_exists(png_path) then
if not lut then
local err
lut, err = cube_lut.parse_cube(text) -- nur bei Cache-Fehltreffer parsen
if not lut then
set_lut_error(data, path, err)
return nil
end
size = lut.size
end
local rows, w, h = cube_lut.atlas_rows(lut, bits)
local ok, werr = write_binary_file(png_path, png_encoder.encode_rgba(w, h, rows, bits))
if not ok then
set_lut_error(data, path, "cannot write cache file: " .. tostring(werr))
return nil
end
end
candidates[#candidates + 1] = { png = png_path, bits = bits }
end
if not size then
set_lut_error(data, path, "missing LUT_3D_SIZE")
return nil
end
data.lut_status = lut_display_name(path) .. ": prepared"
return { path = path, candidates = candidates, size = size, tiles = size, is_cube = true }
end
-- Nur im Grafik-Kontext aufrufen (video_render oder graphics_call).
local function release_lut_texture(data)
if data.lut and data.lut.tex then
pcall(obs.gs_texture_destroy, data.lut.tex)
end
data.lut = nil
end
-- Nur im Grafik-Kontext aufrufen.
local function apply_pending_lut(data)
local pending = data.lut_pending
data.lut_pending = nil
release_lut_texture(data)
data.dirty = true
if not pending then return end
for _, cand in ipairs(pending.candidates) do
local ok, tex = pcall(obs.gs_texture_create_from_file, cand.png)
if ok and tex then
local w, h = obs.gs_texture_get_width(tex), obs.gs_texture_get_height(tex)
local size, tiles = pending.size, pending.tiles
if pending.detect_layout then
local err
size, tiles, err = cube_lut.layout_from_dims(w, h)
if not size then
pcall(obs.gs_texture_destroy, tex)
set_lut_error(data, pending.path, string.format("unsupported PNG layout %dx%d (expected 512x512 grid or N*N x N strip)", w, h))
return
end
end
data.lut = { tex = tex, size = size, tiles = tiles, w = w, h = h, path = pending.path, is_cube = pending.is_cube, bits = cand.bits }
data.lut_status = string.format("%s: %dx%dx%d", lut_display_name(pending.path), size, size, size)
log_info("LUT loaded: " .. data.lut_status)
return
end
log_warn("LUT texture could not be created from " .. tostring(cand.png) .. ", trying next candidate")
if pending.is_cube then os.remove(cand.png) end -- defekte Cache-Datei nicht wiederverwenden
end
set_lut_error(data, pending.path, "texture creation failed")
end
-- ---------------------------------------------------------------------------
-- Look- und LUT-Export
-- ---------------------------------------------------------------------------
local function build_grade_params(data)
local v = data.v
local p = {}
for _, def in ipairs(PARAMS) do
if def.shader then p[def.shader] = v[def.key] end
end
-- grade_pixel erwartet die Settings-Namen der Tonwerte
for _, key in ipairs({ "exposure", "contrast", "brightness", "highlights", "shadows", "whites", "blacks",
"black_lift", "highlight_fade", "shadow_fade", "temperature", "tint", "saturation", "vibrance" }) do
p[key] = v[key]
end
p.shadows_color = { v.shadows_color_r, v.shadows_color_g, v.shadows_color_b }
p.midtones_color = { v.midtones_color_r, v.midtones_color_g, v.midtones_color_b }
p.highlights_color = { v.highlights_color_r, v.highlights_color_g, v.highlights_color_b }
p.split_shadow = { v.ss_r, v.ss_g, v.ss_b, v.ss_sat }
p.split_highlight = { v.sh_r, v.sh_g, v.sh_b, v.sh_sat }
p.lut_strength = v.lut_strength
if data.lut and v.lut_strength > 0.001 then
local cube = nil
if data.lut.is_cube then
local text = read_text_file(data.lut.path)
cube = text and cube_lut.parse_cube(text) or nil -- für den Export frisch parsen
end
if cube then
p.lut = cube
p.lut_sample = cube_lut.sample
else
log_warn("LUT export: the loaded LUT could not be baked into the export (PNG LUT or unreadable file), it is skipped")
end
end
return p
end
local function export_lut_cube(data, path)
local p = build_grade_params(data)
local size = 33
local out = {
"# Created with Lumetric Corrector " .. VERSION .. " for OBS Studio",
"# " .. os.date("%Y-%m-%d %H:%M:%S"),
'TITLE "Lumetric Corrector look"',
"LUT_3D_SIZE " .. size,
"DOMAIN_MIN 0.0 0.0 0.0",
"DOMAIN_MAX 1.0 1.0 1.0",
"",
}
local n = size - 1
for b = 0, n do
for g = 0, n do
for r = 0, n do
local ro, go, bo = grade_pixel.grade(p, r / n, g / n, b / n)
out[#out + 1] = string.format("%.6f %.6f %.6f", ro, go, bo)
end
end
end
out[#out + 1] = ""
return write_text_file(path, table.concat(out, "\n"))
end
-- OBS-kompatibles 512x512-Raster (64³, 8 Kacheln pro Zeile, 8 Bit)
local function export_lut_png(data, path)
local p = build_grade_params(data)
local size, tiles = 64, 8
local n = size - 1
local rows = {}
local char = string.char
for y = 0, size * tiles - 1 do
local g = y % size
local tile_row = math.floor(y / size)
local row = {}
for x = 0, size * tiles - 1 do
local r = x % size
local b = tile_row * tiles + math.floor(x / size)
local ro, go, bo = grade_pixel.grade(p, r / n, g / n, b / n)
row[#row + 1] = char(math.floor(ro * 255 + 0.5), math.floor(go * 255 + 0.5), math.floor(bo * 255 + 0.5), 255)
end
rows[#rows + 1] = table.concat(row)
end
local png = png_encoder.encode_rgba(size * tiles, size * tiles, rows, 8)
return write_binary_file(path, png)
end
local function export_lut(data, path)
if not path or path == "" then return false, "no path" end
local ext = (file_extension(path) or ""):lower()
if ext ~= "cube" and ext ~= "png" then
path = path .. ".cube"
ext = "cube"
end
local ok, err
if ext == "png" then ok, err = export_lut_png(data, path) else ok, err = export_lut_cube(data, path) end
if ok then
log_info("LUT exported: " .. path)
else
log_error("LUT export failed (" .. path .. "): " .. tostring(err))
end
return ok, err
end
local function export_look(data, path)
if not path or path == "" then return false end
if not path:lower():match("%.json$") then path = path .. ".json" end
local d = obs.obs_data_create()
obs.obs_data_set_string(d, "lumetric_look_version", VERSION)
for _, def in ipairs(PARAMS) do
if is_look_key(def.key) then obs.obs_data_set_double(d, def.key, data.v[def.key]) end
end
local ok = obs.obs_data_save_json_safe(d, path, "tmp", "bak")
obs.obs_data_release(d)
if ok then log_info("Look saved: " .. path) else log_error("Look could not be saved: " .. path) end
return ok
end
local function import_look(data, path)
if not path or path == "" or not data.settings then return false end
local d = obs.obs_data_create_from_json_file(path)
if not d then
log_error("Look could not be read: " .. path)
return false
end
local count = 0
for _, def in ipairs(PARAMS) do
if is_look_key(def.key) and obs.obs_data_has_user_value(d, def.key) then
local value = obs.obs_data_get_double(d, def.key)
value = math.max(def.min, math.min(def.max, value))
obs.obs_data_set_double(data.settings, def.key, value)
count = count + 1
end
end
obs.obs_data_release(d)
if count == 0 then
log_warn("Look file contains no known settings: " .. path)
return false
end
obs.obs_source_update(data.source, nil)
log_info(string.format("Look loaded (%d settings): %s", count, path))
return true
end
-- ---------------------------------------------------------------------------
-- Presets anwenden
-- ---------------------------------------------------------------------------
local function apply_preset_to_settings(settings, preset)
for _, def in ipairs(PARAMS) do
if is_look_key(def.key) then
local value = preset.v[def.key]
if value == nil then value = def.def end
obs.obs_data_set_double(settings, def.key, value)
end
end
end
local function reset_settings(settings)
for _, def in ipairs(PARAMS) do
obs.obs_data_set_double(settings, def.key, def.def)
end
obs.obs_data_set_bool(settings, "enable_mask", false)
obs.obs_data_set_double(settings, "mask_shape", 1.0)
obs.obs_data_set_string(settings, "preset_select", "")
end
-- ---------------------------------------------------------------------------
-- Filter-Definition
-- ---------------------------------------------------------------------------
local source_info = {}
source_info.id = "lumetric_corrector_filter"
source_info.type = obs.OBS_SOURCE_TYPE_FILTER
source_info.output_flags = bit.bor(obs.OBS_SOURCE_VIDEO, obs.OBS_SOURCE_CUSTOM_DRAW)
source_info.get_name = function()
return tr("lumetric_corrector")
end
source_info.get_defaults = function(settings)
for _, def in ipairs(PARAMS) do
obs.obs_data_set_default_double(settings, def.key, def.def)
end
obs.obs_data_set_default_bool(settings, "enable_mask", false)
obs.obs_data_set_default_double(settings, "mask_shape", 1.0)
obs.obs_data_set_default_string(settings, "preset_select", "")
obs.obs_data_set_default_string(settings, "lut_path", "")
obs.obs_data_set_default_string(settings, "lut_export_path", default_documents_path("lumetric_look.cube"))
obs.obs_data_set_default_string(settings, "look_export_path", default_documents_path("lumetric_look.json"))
obs.obs_data_set_default_string(settings, "look_import_path", "")
end
-- Eigene Referenz auf die Settings halten: Button-Callbacks laufen, nachdem
-- create/update zurückgekehrt sind; das übergebene Objekt ist nur geliehen.
local function retain_settings(data, settings)
if settings ~= nil then obs.obs_data_addref(settings) end
if data.settings ~= nil then obs.obs_data_release(data.settings) end
data.settings = settings
end
local function cleanup_legacy_keys(settings)
if not obs.obs_data_has_user_value(settings, "dirty") and
not obs.obs_data_has_user_value(settings, "last_exposure") and
not obs.obs_data_has_user_value(settings, "shadows_red") then
return
end
for _, key in ipairs(LEGACY_JUNK_KEYS) do
if obs.obs_data_has_user_value(settings, key) then obs.obs_data_erase(settings, key) end
end
log_info("Removed legacy keys from filter settings")
end
source_info.update = function(data, settings)
if not data then return end
retain_settings(data, settings)
cleanup_legacy_keys(settings)
local v = data.v
for _, def in ipairs(PARAMS) do
v[def.key] = obs.obs_data_get_double(settings, def.key)
end
v.enable_mask = obs.obs_data_get_bool(settings, "enable_mask")
v.mask_shape = obs.obs_data_get_double(settings, "mask_shape")
local lut_path = obs.obs_data_get_string(settings, "lut_path") or ""
if lut_path ~= data.lut_path then
data.lut_path = lut_path
data.lut_pending = prepare_lut(data, lut_path)
data.lut_change = true -- Textur wird im nächsten Render-Aufruf getauscht
end
data.dirty = true
end
-- Uniforms übertragen; nur wenn sich etwas geändert hat (dirty) oder jedes
-- Frame für die animierten Teile.
local function upload_params(data, width, height)
local params = data.params
local v = data.v
if data.dirty then
for _, def in ipairs(PARAMS) do
local param = def.shader and params[def.shader]
if param then obs.gs_effect_set_float(param, v[def.key]) end
end
for _, vec in ipairs(VEC4_PARAMS) do
local param = params[vec.shader]
if param then
local t = data.vec4
t.x = v[vec.keys[1]] or 0
t.y = v[vec.keys[2]] or 0
t.z = v[vec.keys[3]] or 0
t.w = v[vec.keys[4]] or 0
obs.gs_effect_set_vec4(param, t)
end
end
if params.enable_mask then obs.gs_effect_set_float(params.enable_mask, v.enable_mask and 1.0 or 0.0) end
if params.mask_roundness then obs.gs_effect_set_float(params.mask_roundness, v.mask_shape or 1.0) end
if params.mask_pos then
local t = data.vec2
t.x = v.mask_x; t.y = v.mask_y
obs.gs_effect_set_vec2(params.mask_pos, t)
end
if params.mask_size then
local t = data.vec2
t.x = v.mask_width; t.y = v.mask_height
obs.gs_effect_set_vec2(params.mask_size, t)
end
if params.lut_info then
local t = data.vec4
if data.lut then
t.x = data.lut.size; t.y = data.lut.tiles; t.z = 1.0 / data.lut.w; t.w = 1.0 / data.lut.h
else
t.x = 2; t.y = 2; t.z = 1; t.w = 1
end
obs.gs_effect_set_vec4(params.lut_info, t)
end
if params.lut_tex and data.lut and data.lut.tex then
obs.gs_effect_set_texture(params.lut_tex, data.lut.tex)
end
if params.lut_strength and not data.lut then
obs.gs_effect_set_float(params.lut_strength, 0.0) -- keine LUT geladen: Stufe überspringen
end
if params.grain_time then obs.gs_effect_set_float(params.grain_time, data.time) end
data.dirty = false
end
if params.buffer_size and (data.uploaded_width ~= width or data.uploaded_height ~= height) then
local t = data.vec4
t.x = width; t.y = height; t.z = 1.0 / width; t.w = 1.0 / height
obs.gs_effect_set_vec4(params.buffer_size, t)
data.uploaded_width = width
data.uploaded_height = height
end
if params.grain_time and v.grain_amount > 0 then
obs.gs_effect_set_float(params.grain_time, data.time)
end
end
-- OBS-Lua-Filter müssen get_width/get_height liefern: obs-scripting installiert die
-- Callbacks immer, und ohne Lua-Funktion melden sie 0. Da OBS die Größe einer
-- Quelle über ihren ersten Filter abfragt, wäre die Quelle dann unsichtbar.
local function target_size(data, getter)
if not data or not data.source then return 0 end
local target = obs.obs_filter_get_target(data.source)
if target == nil then return 0 end
return getter(target) or 0
end
source_info.get_width = function(data)
return target_size(data, obs.obs_source_get_base_width)
end
source_info.get_height = function(data)
return target_size(data, obs.obs_source_get_base_height)
end
source_info.video_tick = function(data, seconds)
if not data then return end
data.time = (data.time + (seconds or 0)) % 1000
end
-- Shader erst im Render-Thread bauen: dort ist der Grafik-Kontext bereits aktiv,
-- der UI-Thread muss ihn nie anfordern (vermeidet Lock-Wartezeiten beim Anlegen).
local function ensure_effect(data)
if data.effect or data.invalid then return end
local ok, err = pcall(function()
data.effect = obs.gs_effect_create(shader_code, "lumetric_corrector", nil)
if not data.effect then error("gs_effect_create returned nil") end
local names = { "buffer_size", "enable_mask", "mask_pos", "mask_size", "mask_roundness", "lut_info", "lut_tex", "grain_time" }
for _, def in ipairs(PARAMS) do if def.shader then names[#names + 1] = def.shader end end
for _, vec in ipairs(VEC4_PARAMS) do names[#names + 1] = vec.shader end
local count = 0
for _, name in ipairs(names) do
local param = obs.gs_effect_get_param_by_name(data.effect, name)
if param then
data.params[name] = param
count = count + 1
else
log_warn("shader parameter missing: " .. name)
end
end
-- Direct3D 11 verwirft jeden Draw-Aufruf, solange auch nur ein Constant-Buffer-
-- Parameter des Shaders noch nie gesetzt wurde ("Not all shader parameters were
-- set"). Deshalb bekommt jeder Nicht-Textur-Parameter sofort einen Nullwert;
-- die echten Werte folgen im ersten dirty-Upload.
local zero4 = data.vec4
zero4.x, zero4.y, zero4.z, zero4.w = 0, 0, 0, 0
local zero2 = data.vec2
zero2.x, zero2.y = 0, 0
for name, param in pairs(data.params) do
if VEC2_PARAM_NAMES[name] then
obs.gs_effect_set_vec2(param, zero2)
elseif VEC4_PARAM_NAMES[name] then
obs.gs_effect_set_vec4(param, zero4)
elseif not TEXTURE_PARAM_NAMES[name] then
obs.gs_effect_set_float(param, 0.0)
end
end
log_info(string.format("v%s shader ready (%d parameters)", VERSION, count))
end)
if not ok then
data.invalid = true
log_error("shader could not be created: " .. tostring(err))
end
data.dirty = true
data.uploaded_width = nil
end
source_info.video_render = function(data)
if not data or not data.source then return end
ensure_effect(data)
if not data.effect or data.invalid then
obs.obs_source_skip_video_filter(data.source)
return
end
if data.lut_change then
data.lut_change = false
apply_pending_lut(data)
end
local target = obs.obs_filter_get_target(data.source)
if target == nil then
obs.obs_source_skip_video_filter(data.source)
return
end
local width = obs.obs_source_get_base_width(target)
local height = obs.obs_source_get_base_height(target)
if width == 0 or height == 0 then
obs.obs_source_skip_video_filter(data.source)
return
end
-- OBS besitzt den Draw-Pass zwischen begin und end. Ein Lua-Fehler beim Setzen
-- der Uniforms darf den Pass nicht offen lassen.
local begun = false
local ok, err = pcall(function()
begun = obs.obs_source_process_filter_begin(data.source, obs.GS_RGBA, obs.OBS_NO_DIRECT_RENDERING)
if not begun then return end
upload_params(data, width, height)
obs.obs_source_process_filter_end(data.source, data.effect, width, height)
begun = false
end)
if begun then
pcall(obs.obs_source_process_filter_end, data.source, data.effect, width, height)
end
if not ok then
data.render_errors = (data.render_errors or 0) + 1
if data.render_errors <= 3 then log_error("Render error: " .. tostring(err)) end
end
end
source_info.create = function(settings, source)
local data = {
source = source,
settings = nil,
v = {},
params = {},
lut = nil,
lut_path = nil,
lut_status = "",
time = 0,
dirty = true,
vec2 = obs.vec2(),
vec4 = obs.vec4(),
}
for _, def in ipairs(PARAMS) do data.v[def.key] = def.def end
data.v.enable_mask = false
data.v.mask_shape = 1.0
-- Shader und LUT-Textur entstehen beim ersten Rendern (siehe ensure_effect).
source_info.update(data, settings)
return data
end
source_info.destroy = function(data)
if not data then return end
if data.effect or (data.lut and data.lut.tex) then
graphics_call(function()
if data.effect then pcall(obs.gs_effect_destroy, data.effect) end
release_lut_texture(data)
end)
end
data.effect = nil
data.lut = nil
data.lut_pending = nil
if data.settings ~= nil then
obs.obs_data_release(data.settings)
data.settings = nil
end
end
-- ---------------------------------------------------------------------------
-- Eigenschaften (UI)
-- ---------------------------------------------------------------------------
local function add_slider(group, def)
local prop = obs.obs_properties_add_float_slider(group, def.key, tr(def.key), def.min, def.max, def.step)
if def.desc then obs.obs_property_set_long_description(prop, tr(def.desc)) end
return prop
end
local function add_group_with_sliders(props, group_id, def_filter)
local group = obs.obs_properties_create()
for _, def in ipairs(PARAMS) do
if def.group == group_id and (not def_filter or def_filter(def)) then add_slider(group, def) end
end
obs.obs_properties_add_group(props, group_id .. "_group", tr(group_id), obs.OBS_GROUP_NORMAL, group)
return group
end
source_info.get_properties = function(data)
local props = obs.obs_properties_create()
-- Presets ---------------------------------------------------------------
local preset_group = obs.obs_properties_create()
local preset_list = obs.obs_properties_add_list(preset_group, "preset_select", tr("select_preset"),
obs.OBS_COMBO_TYPE_LIST, obs.OBS_COMBO_FORMAT_STRING)
obs.obs_property_set_long_description(preset_list, tr("select_preset_desc"))
obs.obs_property_list_add_string(preset_list, "", "")
for _, cat in ipairs(PRESET_CATEGORIES) do
local idx = obs.obs_property_list_add_string(preset_list, tr(cat.id), "separator_" .. cat.id)
obs.obs_property_list_item_disable(preset_list, idx, true)
for _, preset in ipairs(cat.presets) do
obs.obs_property_list_add_string(preset_list, preset_name(preset.id), preset.id)
end
end
obs.obs_property_set_modified_callback(preset_list, function(_, _, settings)
local preset = PRESET_BY_ID[obs.obs_data_get_string(settings, "preset_select")]
if not preset then return false end
apply_preset_to_settings(settings, preset)
if data and data.source then obs.obs_source_update(data.source, nil) end
return true
end)
obs.obs_properties_add_button(preset_group, "apply_preset_button", tr("apply_preset"), function()
if not data or not data.settings then return false end
local preset = PRESET_BY_ID[obs.obs_data_get_string(data.settings, "preset_select")]
if not preset then return false end
apply_preset_to_settings(data.settings, preset)
obs.obs_source_update(data.source, nil)
return true
end)
obs.obs_properties_add_button(preset_group, "reset_button", tr("reset_all"), function()
if not data or not data.settings then return false end
reset_settings(data.settings)
obs.obs_source_update(data.source, nil)
return true
end)
obs.obs_properties_add_group(props, "presets_group", tr("presets"), obs.OBS_GROUP_NORMAL, preset_group)
-- Regler-Gruppen -------------------------------------------------------------
add_group_with_sliders(props, "tone")
add_group_with_sliders(props, "fade")
add_group_with_sliders(props, "wb")
add_group_with_sliders(props, "color")
add_group_with_sliders(props, "wheels")
add_group_with_sliders(props, "split")
add_group_with_sliders(props, "fx")
add_group_with_sliders(props, "vignette")
-- Maske (abhakbare Gruppe, Schlüssel enable_mask) -------------------------------
local mask_group = obs.obs_properties_create()
local shape_list = obs.obs_properties_add_list(mask_group, "mask_shape", tr("mask_shape"),
obs.OBS_COMBO_TYPE_LIST, obs.OBS_COMBO_FORMAT_FLOAT)
obs.obs_property_list_add_float(shape_list, tr("mask_round"), 1.0)
obs.obs_property_list_add_float(shape_list, tr("mask_rect"), 0.0)
for _, def in ipairs(PARAMS) do
if def.group == "mask" then add_slider(mask_group, def) end
end
obs.obs_properties_add_group(props, "enable_mask", tr("mask"), obs.OBS_GROUP_CHECKABLE, mask_group)
-- LUT --------------------------------------------------------------------------
local lut_group = obs.obs_properties_create()
local lut_path_prop = obs.obs_properties_add_path(lut_group, "lut_path", tr("lut_path"), obs.OBS_PATH_FILE,
"LUT (*.cube *.png);;Cube LUT (*.cube);;PNG LUT (*.png);;All files (*.*)", nil)
obs.obs_property_set_long_description(lut_path_prop, tr("lut_path_desc"))
add_slider(lut_group, PARAM_BY_KEY.lut_strength)
local status_text = (data and data.lut_status and data.lut_status ~= "") and data.lut_status or "-"
local status_prop = obs.obs_properties_add_text(lut_group, "lut_status_text", tr("lut_status") .. ": " .. status_text, obs.OBS_TEXT_INFO)
if data and data.lut_error and obs.obs_property_text_set_info_type then
obs.obs_property_text_set_info_type(status_prop, obs.OBS_TEXT_INFO_WARNING)
end
obs.obs_property_set_modified_callback(lut_path_prop, function() return true end) -- Statuszeile aktualisieren
obs.obs_properties_add_group(props, "lut_group", tr("lut"), obs.OBS_GROUP_NORMAL, lut_group)
-- Looks & Export -----------------------------------------------------------------
local looks_group = obs.obs_properties_create()
obs.obs_properties_add_text(looks_group, "look_info", tr("look_desc"), obs.OBS_TEXT_INFO)
-- Ergebnis der letzten Aktion (Speichern/Laden/Export) sichtbar machen; die Buttons
-- geben true zurück, damit OBS die Eigenschaften neu aufbaut und die Zeile aktualisiert.
local function record_action(ok, label, path)
if not data then return true end
data.action_status = (ok and "OK: " or "ERROR: ") .. label .. " " .. tostring(path)
data.action_failed = not ok
return true
end
obs.obs_properties_add_path(looks_group, "look_export_path", tr("look_export_path"), obs.OBS_PATH_FILE_SAVE,
"Lumetric Look (*.json)", default_documents_path("lumetric_look.json"))
obs.obs_properties_add_button(looks_group, "save_look_button", tr("save_look"), function()
if not data or not data.settings then return false end
local path = obs.obs_data_get_string(data.settings, "look_export_path")
return record_action(export_look(data, path), tr("save_look"), path)
end)
obs.obs_properties_add_path(looks_group, "look_import_path", tr("look_import_path"), obs.OBS_PATH_FILE,
"Lumetric Look (*.json);;All files (*.*)", nil)
obs.obs_properties_add_button(looks_group, "load_look_button", tr("load_look"), function()
if not data or not data.settings then return false end
local path = obs.obs_data_get_string(data.settings, "look_import_path")
return record_action(import_look(data, path), tr("load_look"), path)
end)
local lut_export_prop = obs.obs_properties_add_path(looks_group, "lut_export_path", tr("lut_export_path"), obs.OBS_PATH_FILE_SAVE,
"Cube LUT (*.cube);;PNG LUT (*.png)", default_documents_path("lumetric_look.cube"))
obs.obs_property_set_long_description(lut_export_prop, tr("lut_export_path_desc"))
local export_button = obs.obs_properties_add_button(looks_group, "export_lut_button", tr("export_lut"), function()
if not data or not data.settings then return false end
local path = obs.obs_data_get_string(data.settings, "lut_export_path")
local ok = export_lut(data, path)
return record_action(ok, tr("export_lut"), path)
end)
obs.obs_property_set_long_description(export_button, tr("export_lut_desc"))
if data and data.action_status then
local action_prop = obs.obs_properties_add_text(looks_group, "action_status_text", data.action_status, obs.OBS_TEXT_INFO)
if data.action_failed and obs.obs_property_text_set_info_type then
obs.obs_property_text_set_info_type(action_prop, obs.OBS_TEXT_INFO_WARNING)
end
end
obs.obs_properties_add_group(props, "looks_group", tr("looks"), obs.OBS_GROUP_NORMAL, looks_group)
-- Info ---------------------------------------------------------------------------
obs.obs_properties_add_text(props, "info_text", string.format(tr("info_text"), VERSION), obs.OBS_TEXT_INFO)
return props
end
-- ---------------------------------------------------------------------------
-- Skript-Einstieg
-- ---------------------------------------------------------------------------
function script_description()
return string.format(
"%s v%s
" ..
"Color correction and grading filter: tone, white balance, color wheels, split toning, " ..
"bloom, halation, sharpen, film grain, vignette, mask, 3D LUT (.cube/.png), LUT and look export, %d presets.
" ..
"Add it via Filters → + → %s on any source or scene.
" ..
"GitHub · " ..
"Discord",
tr("lumetric_corrector"), VERSION, PRESET_COUNT, tr("lumetric_corrector"))
end
function script_load(settings)
obs.obs_register_source(source_info)
log_info("Lumetric Corrector v" .. VERSION .. " registered")
end
function script_unload()
end
-- Testhaken: nur aktiv, wenn die Testumgebung vor dem Laden ein Global setzt.
if rawget(_G, "LUMETRIC_TEST_HOOK") then
LUMETRIC_TEST_HOOK({
VERSION = VERSION, PARAMS = PARAMS, PARAM_BY_KEY = PARAM_BY_KEY, VEC4_PARAMS = VEC4_PARAMS,
PRESET_CATEGORIES = PRESET_CATEGORIES, PRESET_BY_ID = PRESET_BY_ID, L10N = L10N, PRESET_NAMES = PRESET_NAMES,
LEGACY_JUNK_KEYS = LEGACY_JUNK_KEYS, shader_code = shader_code, source_info = source_info,
png_encoder = png_encoder, cube_lut = cube_lut, grade_pixel = grade_pixel,
export_lut = export_lut, export_look = export_look, import_look = import_look,
apply_preset_to_settings = apply_preset_to_settings, reset_settings = reset_settings,
prepare_lut = prepare_lut, lut_cache_dir = lut_cache_dir, tr = tr, is_look_key = is_look_key,
})
end