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:
@@ -51,6 +51,7 @@ def _pp_template_to_response(t) -> PostprocessingTemplateResponse:
|
|||||||
tags=t.tags,
|
tags=t.tags,
|
||||||
icon=getattr(t, "icon", "") or "",
|
icon=getattr(t, "icon", "") or "",
|
||||||
icon_color=getattr(t, "icon_color", "") 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,
|
max_length=32,
|
||||||
description="Optional CSS color override for the icon. Empty/null inherits the channel accent.",
|
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):
|
class PostprocessingTemplateListResponse(BaseModel):
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ export interface PostprocessingTemplate {
|
|||||||
description?: string;
|
description?: string;
|
||||||
icon?: string;
|
icon?: string;
|
||||||
icon_color?: string;
|
icon_color?: string;
|
||||||
|
is_builtin?: boolean;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ class PostprocessingTemplate:
|
|||||||
tags: List[str] = field(default_factory=list)
|
tags: List[str] = field(default_factory=list)
|
||||||
icon: str = ""
|
icon: str = ""
|
||||||
icon_color: str = ""
|
icon_color: str = ""
|
||||||
|
is_builtin: bool = False
|
||||||
|
|
||||||
def to_dict(self) -> dict:
|
def to_dict(self) -> dict:
|
||||||
"""Convert template to dictionary."""
|
"""Convert template to dictionary."""
|
||||||
@@ -31,6 +32,7 @@ class PostprocessingTemplate:
|
|||||||
"updated_at": self.updated_at.isoformat(),
|
"updated_at": self.updated_at.isoformat(),
|
||||||
"description": self.description,
|
"description": self.description,
|
||||||
"tags": self.tags,
|
"tags": self.tags,
|
||||||
|
"is_builtin": self.is_builtin,
|
||||||
}
|
}
|
||||||
if self.icon:
|
if self.icon:
|
||||||
d["icon"] = self.icon
|
d["icon"] = self.icon
|
||||||
@@ -61,4 +63,5 @@ class PostprocessingTemplate:
|
|||||||
tags=data.get("tags", []),
|
tags=data.get("tags", []),
|
||||||
icon=data.get("icon", "") or "",
|
icon=data.get("icon", "") or "",
|
||||||
icon_color=data.get("icon_color", "") 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__)
|
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]):
|
class PostprocessingTemplateStore(BaseSqliteStore[PostprocessingTemplate]):
|
||||||
"""Storage for postprocessing templates.
|
"""Storage for postprocessing templates.
|
||||||
|
|
||||||
@@ -29,11 +80,42 @@ class PostprocessingTemplateStore(BaseSqliteStore[PostprocessingTemplate]):
|
|||||||
def __init__(self, db: Database):
|
def __init__(self, db: Database):
|
||||||
super().__init__(db, PostprocessingTemplate.from_dict)
|
super().__init__(db, PostprocessingTemplate.from_dict)
|
||||||
self._ensure_initial_template()
|
self._ensure_initial_template()
|
||||||
|
self._seed_missing_builtins()
|
||||||
|
|
||||||
# Backward-compatible aliases
|
# Backward-compatible aliases
|
||||||
get_all_templates = BaseSqliteStore.get_all
|
get_all_templates = BaseSqliteStore.get_all
|
||||||
get_template = BaseSqliteStore.get
|
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:
|
def _ensure_initial_template(self) -> None:
|
||||||
"""Auto-create a default postprocessing template if none exist."""
|
"""Auto-create a default postprocessing template if none exist."""
|
||||||
@@ -114,6 +196,9 @@ class PostprocessingTemplateStore(BaseSqliteStore[PostprocessingTemplate]):
|
|||||||
) -> PostprocessingTemplate:
|
) -> PostprocessingTemplate:
|
||||||
template = self.get(template_id)
|
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:
|
if name is not None:
|
||||||
self._check_name_unique(name, exclude_id=template_id)
|
self._check_name_unique(name, exclude_id=template_id)
|
||||||
template.name = name
|
template.name = name
|
||||||
|
|||||||
@@ -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
|
||||||
Reference in New Issue
Block a user