feat(processing): built-in 'look' presets (Cinematic/Vivid/Cozy/Soft/Cool)

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.)
This commit is contained in:
2026-06-04 23:43:11 +03:00
parent 7728aecb4f
commit e18d56c838
6 changed files with 173 additions and 1 deletions
@@ -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),
)
@@ -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):
@@ -30,6 +30,7 @@ export interface PostprocessingTemplate {
description?: string;
icon?: string;
icon_color?: string;
is_builtin?: boolean;
created_at: string;
updated_at: string;
}
@@ -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),
)
@@ -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
+81
View File
@@ -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