From e18d56c838ebd1d00af4a7b354adcf3216b35812 Mon Sep 17 00:00:00 2001 From: "alexei.dolgolyov" Date: Thu, 4 Jun 2026 23:43:11 +0300 Subject: [PATCH] feat(processing): built-in 'look' presets (Cinematic/Vivid/Cozy/Soft/Cool) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Seed five curated, read-only post-processing templates so a non-expert gets instant good-looking output before discovering the filter pipeline. Each is an opinionated chain of existing filters (auto-crop/saturation/contrast/colour- temperature/temporal-blur) tuned for a use case (films, games, evening ambience, low-flicker, crisp cool-white). Mirrors the built-in-gradient pattern: adds is_builtin to PostprocessingTemplate, seeds missing looks on store init (idempotent, additive — no migration), and makes built-ins read-only (update/delete raise -> 400; clone to customise). Surfaced via the existing template picker + is_builtin in the response/type. 7 unit tests (seeding, idempotency, read-only protection, round-trip); full suite green (1926 passed). (A runtime intensity slider is a follow-up — it needs a filter-chain parameterisation layer.) --- .../src/ledgrab/api/routes/postprocessing.py | 1 + .../src/ledgrab/api/schemas/postprocessing.py | 1 + .../src/ledgrab/static/js/types/template.ts | 1 + .../storage/postprocessing_template.py | 3 + .../storage/postprocessing_template_store.py | 87 ++++++++++++++++++- server/tests/test_postprocessing_looks.py | 81 +++++++++++++++++ 6 files changed, 173 insertions(+), 1 deletion(-) create mode 100644 server/tests/test_postprocessing_looks.py diff --git a/server/src/ledgrab/api/routes/postprocessing.py b/server/src/ledgrab/api/routes/postprocessing.py index 3bf9e69..d84d9db 100644 --- a/server/src/ledgrab/api/routes/postprocessing.py +++ b/server/src/ledgrab/api/routes/postprocessing.py @@ -51,6 +51,7 @@ def _pp_template_to_response(t) -> PostprocessingTemplateResponse: tags=t.tags, icon=getattr(t, "icon", "") or "", icon_color=getattr(t, "icon_color", "") or "", + is_builtin=getattr(t, "is_builtin", False), ) diff --git a/server/src/ledgrab/api/schemas/postprocessing.py b/server/src/ledgrab/api/schemas/postprocessing.py index 0b4e3d3..583f615 100644 --- a/server/src/ledgrab/api/schemas/postprocessing.py +++ b/server/src/ledgrab/api/schemas/postprocessing.py @@ -70,6 +70,7 @@ class PostprocessingTemplateResponse(BaseModel): max_length=32, description="Optional CSS color override for the icon. Empty/null inherits the channel accent.", ) + is_builtin: bool = Field(default=False, description="True for read-only curated 'look' presets") class PostprocessingTemplateListResponse(BaseModel): diff --git a/server/src/ledgrab/static/js/types/template.ts b/server/src/ledgrab/static/js/types/template.ts index 1624e2d..cb61e4e 100644 --- a/server/src/ledgrab/static/js/types/template.ts +++ b/server/src/ledgrab/static/js/types/template.ts @@ -30,6 +30,7 @@ export interface PostprocessingTemplate { description?: string; icon?: string; icon_color?: string; + is_builtin?: boolean; created_at: string; updated_at: string; } diff --git a/server/src/ledgrab/storage/postprocessing_template.py b/server/src/ledgrab/storage/postprocessing_template.py index ae6bd75..e0c78e5 100644 --- a/server/src/ledgrab/storage/postprocessing_template.py +++ b/server/src/ledgrab/storage/postprocessing_template.py @@ -20,6 +20,7 @@ class PostprocessingTemplate: tags: List[str] = field(default_factory=list) icon: str = "" icon_color: str = "" + is_builtin: bool = False def to_dict(self) -> dict: """Convert template to dictionary.""" @@ -31,6 +32,7 @@ class PostprocessingTemplate: "updated_at": self.updated_at.isoformat(), "description": self.description, "tags": self.tags, + "is_builtin": self.is_builtin, } if self.icon: d["icon"] = self.icon @@ -61,4 +63,5 @@ class PostprocessingTemplate: tags=data.get("tags", []), icon=data.get("icon", "") or "", icon_color=data.get("icon_color", "") or "", + is_builtin=data.get("is_builtin", False), ) diff --git a/server/src/ledgrab/storage/postprocessing_template_store.py b/server/src/ledgrab/storage/postprocessing_template_store.py index 240c62f..27fd1c8 100644 --- a/server/src/ledgrab/storage/postprocessing_template_store.py +++ b/server/src/ledgrab/storage/postprocessing_template_store.py @@ -15,6 +15,57 @@ from ledgrab.utils import get_logger logger = get_logger(__name__) +# Curated, read-only "look" presets — opinionated filter chains that give +# instant good-looking output before a user discovers the filter pipeline. +# Each entry: id-suffix -> (display name, description, [(filter_id, options), ...]). +# Only verified filters/option keys are used. +_BUILTIN_LOOKS: dict[str, tuple[str, str, list[tuple[str, dict]]]] = { + "cinematic": ( + "Cinematic", + "Letterbox-aware, gently smoothed, mild colour boost — tuned for films.", + [ + ("auto_crop", {"threshold": 16, "min_bar_size": 20, "min_aspect_ratio": 1.4}), + ("saturation", {"value": 1.12}), + ("temporal_blur", {"strength": 0.35}), + ], + ), + "vivid": ( + "Vivid", + "Punchy and responsive with high saturation — tuned for games.", + [ + ("saturation", {"value": 1.4}), + ("contrast", {"value": 1.18}), + ], + ), + "cozy": ( + "Cozy", + "Warm, dim and smooth — relaxed evening ambience.", + [ + ("color_correction", {"temperature": 3800}), + ("brightness", {"value": 0.85}), + ("saturation", {"value": 0.95}), + ("temporal_blur", {"strength": 0.45}), + ], + ), + "soft": ( + "Soft", + "Heavily smoothed and calm — minimises flicker on busy content.", + [ + ("temporal_blur", {"strength": 0.55}), + ("saturation", {"value": 0.98}), + ], + ), + "cool": ( + "Cool", + "Crisp, cool-white and clean — a modern, neutral look.", + [ + ("color_correction", {"temperature": 8000}), + ("saturation", {"value": 1.1}), + ], + ), +} + + class PostprocessingTemplateStore(BaseSqliteStore[PostprocessingTemplate]): """Storage for postprocessing templates. @@ -29,11 +80,42 @@ class PostprocessingTemplateStore(BaseSqliteStore[PostprocessingTemplate]): def __init__(self, db: Database): super().__init__(db, PostprocessingTemplate.from_dict) self._ensure_initial_template() + self._seed_missing_builtins() # Backward-compatible aliases get_all_templates = BaseSqliteStore.get_all get_template = BaseSqliteStore.get - delete_template = BaseSqliteStore.delete + + def _seed_missing_builtins(self) -> None: + """Seed any curated built-in "look" templates not yet in the store.""" + now = datetime.now(timezone.utc) + added = 0 + for key, (name, description, chain) in _BUILTIN_LOOKS.items(): + tid = f"pp_builtin_{key}" + if tid in self._items: + continue + template = PostprocessingTemplate( + id=tid, + name=name, + filters=[FilterInstance(fid, dict(opts)) for fid, opts in chain], + created_at=now, + updated_at=now, + description=description, + tags=["look"], + is_builtin=True, + ) + self._items[tid] = template + self._save_item(tid, template) + added += 1 + if added: + logger.info(f"Seeded {added} new built-in look templates") + + def delete_template(self, template_id: str) -> None: + """Delete a template. Built-in looks are read-only.""" + template = self.get(template_id) + if getattr(template, "is_builtin", False): + raise ValueError("Built-in look templates cannot be deleted. Clone to customise.") + self.delete(template_id) def _ensure_initial_template(self) -> None: """Auto-create a default postprocessing template if none exist.""" @@ -114,6 +196,9 @@ class PostprocessingTemplateStore(BaseSqliteStore[PostprocessingTemplate]): ) -> PostprocessingTemplate: template = self.get(template_id) + if getattr(template, "is_builtin", False): + raise ValueError("Built-in look templates are read-only. Clone to customise.") + if name is not None: self._check_name_unique(name, exclude_id=template_id) template.name = name diff --git a/server/tests/test_postprocessing_looks.py b/server/tests/test_postprocessing_looks.py new file mode 100644 index 0000000..adaf9d4 --- /dev/null +++ b/server/tests/test_postprocessing_looks.py @@ -0,0 +1,81 @@ +"""Tests for built-in curated 'look' postprocessing templates.""" + +import pytest + +from ledgrab.core.filters.registry import FilterRegistry +from ledgrab.storage.postprocessing_template import PostprocessingTemplate +from ledgrab.storage.postprocessing_template_store import ( + _BUILTIN_LOOKS, + PostprocessingTemplateStore, +) + + +def test_builtins_are_seeded(tmp_db): + store = PostprocessingTemplateStore(tmp_db) + for key in _BUILTIN_LOOKS: + tpl = store.get_template(f"pp_builtin_{key}") + assert tpl.is_builtin is True + assert tpl.filters # non-empty chain + + +def test_builtin_filters_use_registered_ids(tmp_db): + store = PostprocessingTemplateStore(tmp_db) + for key in _BUILTIN_LOOKS: + tpl = store.get_template(f"pp_builtin_{key}") + for fi in tpl.filters: + assert FilterRegistry.is_registered(fi.filter_id), fi.filter_id + + +def test_seeding_is_idempotent(tmp_db): + PostprocessingTemplateStore(tmp_db) + store2 = PostprocessingTemplateStore(tmp_db) + ids = [t.id for t in store2.get_all_templates() if t.id.startswith("pp_builtin_")] + assert sorted(ids) == sorted(f"pp_builtin_{k}" for k in _BUILTIN_LOOKS) + + +def test_builtin_update_is_blocked(tmp_db): + store = PostprocessingTemplateStore(tmp_db) + with pytest.raises(ValueError, match="read-only"): + store.update_template("pp_builtin_vivid", name="Hacked") + + +def test_builtin_delete_is_blocked(tmp_db): + store = PostprocessingTemplateStore(tmp_db) + with pytest.raises(ValueError, match="cannot be deleted"): + store.delete_template("pp_builtin_vivid") + + +def test_user_template_still_editable_and_deletable(tmp_db): + store = PostprocessingTemplateStore(tmp_db) + tpl = store.create_template("My Look", filters=[]) + assert tpl.is_builtin is False + store.update_template(tpl.id, description="changed") + store.delete_template(tpl.id) + with pytest.raises(ValueError): + store.get_template(tpl.id) + + +def test_is_builtin_round_trips_through_dict(): + tpl = PostprocessingTemplate.from_dict( + { + "id": "pp_x", + "name": "x", + "filters": [], + "created_at": "2026-01-01T00:00:00+00:00", + "updated_at": "2026-01-01T00:00:00+00:00", + "is_builtin": True, + } + ) + assert tpl.is_builtin is True + assert tpl.to_dict()["is_builtin"] is True + # legacy dict without the field defaults to False + legacy = PostprocessingTemplate.from_dict( + { + "id": "pp_y", + "name": "y", + "filters": [], + "created_at": "2026-01-01T00:00:00+00:00", + "updated_at": "2026-01-01T00:00:00+00:00", + } + ) + assert legacy.is_builtin is False