Skip to content

Commit d690b76

Browse files
committed
v1.0: starter render-settings templates (3 ship)
DDON_PLAN.md §3 v1.0 calls for "Starter Studio templates ship with the addon (Three-point lighting, Product turntable, Architectural day/night). First-run activation moment". Full scene-setup templates need bundled .blend assets — that's v1.x. For v1.0 ship render- settings starter Studios instead: pre-configured engine + resolution + per-engine settings bag, applied on top of whatever the user has in their scene currently. - stage.core.templates: TEMPLATES dict with three entries: - cycles_quality : Cycles 256 samples + denoising + max_bounces 12, 1080p - eevee_preview : EEVEE Next, 16 TAA samples, 720p (fast iteration) - print_4k : Cycles 512 samples + denoising, 3840×2160 - apply_template_to_studio(studio, template) overlays the template onto Studio.facet_render. Settings entries use stage.core.store_ property.encode_value so each RNA path → value pair gets the right value_type tag (INT / FLOAT / BOOL etc). - stage.ops.STAGE_OT_studio_add_from_template captures the user's current scene first (so the new Studio inherits camera / world / visibility / output path from where they are), then overlays the template's render settings on the captured render facet, then renders a thumbnail. Wraps the whole thing in set_apply_in_progress so the dirty handler stays quiet. - stage.ops.STAGE_MT_templates Menu lists each template; surfaced in the N-panel via col.menu("STAGE_MT_templates", icon='PRESET'). Sits next to the Add / Remove / Duplicate row. - Templates explicitly do NOT carry an output_pattern. Setting studio.output_override = "//foo" would trigger the FILE_PATH subtype's blend-relative-prefix RuntimeWarning we already hit on OutputPathFacet.capture. Output paths fall through to addon prefs default_output_pattern instead — clean separation, no warning. - 8 tests: TEMPLATES has the documented three entries, every template has the keys apply_template_to_studio relies on, each template's settings map correctly onto facet_render, settings-bag replacement clears stale entries, operator integration, unknown- template error path (RuntimeError from bpy.ops + ERROR report). 107/107 passing on Blender 5.1.0 (17 unit + 90 Blender). Phase 1 + v1.0 ledger: Shipped: 5 facets, dirty-state, thumbnails, render ops, locked Studios, tag filter, inheritance, right-click → Store, Lister, click-to-preview, starter templates Remaining headliners: Studio Groups, multi-edit, post-render actions, background rendering subprocess, persistent SQLite queue
1 parent 93a3740 commit d690b76

5 files changed

Lines changed: 305 additions & 1 deletion

File tree

stage/core/templates.py

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
"""Starter Studio templates — pre-configured Studios for first-install activation.
2+
3+
Each template is a dict with engine + resolution + per-engine settings +
4+
default output pattern. The template operator (stage.ops.templates) creates
5+
a new Studio, captures the user's current scene state for non-render facets,
6+
then overlays the template's render settings on top.
7+
8+
Three render-settings templates ship in v1.0:
9+
- Cycles Quality : 256 samples, denoising, max_bounces=12, 1080p
10+
- EEVEE Preview : EEVEE Next, low samples, 720p (fast iteration)
11+
- Print 4K : Cycles 512 samples + denoising, 3840×2160
12+
13+
Full scene-setup templates (Three-point lighting, Product turntable,
14+
Architectural day/night per the plan) require bundled .blend assets and
15+
land in v1.x.
16+
"""
17+
18+
from __future__ import annotations
19+
20+
from .store_property import encode_value
21+
22+
23+
# Templates carry render settings only — output paths fall through to the
24+
# addon-prefs default_output_pattern (or a per-Studio override the user picks
25+
# via the FILE_PATH browse button). Setting output_override from the template
26+
# would inherit the "//" relative-prefix issue (FILE_PATH subtype rejects it).
27+
TEMPLATES: dict[str, dict] = {
28+
"cycles_quality": {
29+
"display_name": "Cycles Quality",
30+
"engine": "CYCLES",
31+
"resolution_x": 1920,
32+
"resolution_y": 1080,
33+
"resolution_percentage": 100,
34+
"render_settings": {
35+
"cycles.samples": 256,
36+
"cycles.use_denoising": True,
37+
"cycles.max_bounces": 12,
38+
},
39+
},
40+
"eevee_preview": {
41+
"display_name": "EEVEE Preview",
42+
"engine": "BLENDER_EEVEE",
43+
"resolution_x": 1280,
44+
"resolution_y": 720,
45+
"resolution_percentage": 100,
46+
"render_settings": {
47+
"eevee.taa_render_samples": 16,
48+
},
49+
},
50+
"print_4k": {
51+
"display_name": "Print 4K",
52+
"engine": "CYCLES",
53+
"resolution_x": 3840,
54+
"resolution_y": 2160,
55+
"resolution_percentage": 100,
56+
"render_settings": {
57+
"cycles.samples": 512,
58+
"cycles.use_denoising": True,
59+
},
60+
},
61+
}
62+
63+
64+
def apply_template_to_studio(studio, template: dict) -> None:
65+
"""Overlay a template's render settings onto a Studio's facet_render.
66+
67+
Doesn't touch other facets — caller is expected to have already run
68+
capture_all(scene, studio) so camera / world / visibility are captured
69+
from the user's current scene.
70+
"""
71+
f = studio.facet_render
72+
f.captured = True
73+
f.engine = template["engine"]
74+
75+
if "resolution_x" in template:
76+
f.resolution_x = template["resolution_x"]
77+
if "resolution_y" in template:
78+
f.resolution_y = template["resolution_y"]
79+
if "resolution_percentage" in template:
80+
f.resolution_percentage = template["resolution_percentage"]
81+
82+
# Replace the engine-specific settings bag with the template's entries
83+
f.settings.clear()
84+
for path, value in template.get("render_settings", {}).items():
85+
entry = f.settings.add()
86+
entry.data_path = path
87+
encoded, type_tag = encode_value(value)
88+
entry.value_repr = encoded
89+
entry.value_type = type_tag

stage/ops/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,10 @@
55
from . import thumbnails
66
from . import render
77
from . import store_property
8+
from . import templates
89

910

10-
_modules = (studio_crud, apply, thumbnails, render, store_property)
11+
_modules = (studio_crud, apply, thumbnails, render, store_property, templates)
1112

1213

1314
def register() -> None:

stage/ops/templates.py

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
"""Add Studio from a starter template — operator + dropdown menu."""
2+
3+
import uuid
4+
5+
import bpy
6+
from bpy.types import Menu, Operator
7+
from bpy.props import StringProperty
8+
9+
from ..core.facets import capture_all
10+
from ..core.templates import TEMPLATES, apply_template_to_studio
11+
from ..core.thumbnails import render_thumbnail
12+
from ..handlers import set_apply_in_progress
13+
from ..utils.logger import get_logger
14+
15+
16+
_log = get_logger()
17+
18+
19+
class STAGE_OT_studio_add_from_template(Operator):
20+
bl_idname = "stage.studio_add_from_template"
21+
bl_label = "Add Studio from Template"
22+
bl_description = "Create a new Studio pre-configured with a starter template's render settings"
23+
bl_options = {'REGISTER', 'UNDO'}
24+
25+
template_id: StringProperty()
26+
27+
def execute(self, context):
28+
template = TEMPLATES.get(self.template_id)
29+
if template is None:
30+
self.report({'ERROR'}, f"Unknown template: {self.template_id}")
31+
return {'CANCELLED'}
32+
33+
data = context.scene.stage_data
34+
new_studio = data.studios.add()
35+
new_studio.name = template["display_name"]
36+
new_studio.uuid = str(uuid.uuid4())
37+
data.active_index = len(data.studios) - 1
38+
39+
set_apply_in_progress(True)
40+
try:
41+
# Capture the user's current scene first — gets camera, world,
42+
# visibility, output path. Then overlay the template on top of
43+
# the render facet. Output path stays as captured / falls through
44+
# to the addon-prefs default; templates carry render settings only.
45+
capture_all(context.scene, new_studio)
46+
apply_template_to_studio(new_studio, template)
47+
48+
render_thumbnail(context.scene, new_studio)
49+
50+
data.last_applied_studio_uuid = new_studio.uuid
51+
data.dirty = False
52+
data.suppress_next_dirty_fire = True
53+
finally:
54+
set_apply_in_progress(False)
55+
56+
self.report({'INFO'}, f"Added Studio from template: {template['display_name']}")
57+
_log.info("Added Studio from template: %s", template["display_name"])
58+
return {'FINISHED'}
59+
60+
61+
class STAGE_MT_templates(Menu):
62+
bl_idname = "STAGE_MT_templates"
63+
bl_label = "Starter Templates"
64+
65+
def draw(self, context):
66+
for tid, template in TEMPLATES.items():
67+
op = self.layout.operator(
68+
STAGE_OT_studio_add_from_template.bl_idname,
69+
text=template["display_name"],
70+
icon='RENDER_STILL',
71+
)
72+
op.template_id = tid
73+
74+
75+
_classes = (STAGE_OT_studio_add_from_template, STAGE_MT_templates)
76+
77+
78+
def register() -> None:
79+
for cls in _classes:
80+
bpy.utils.register_class(cls)
81+
82+
83+
def unregister() -> None:
84+
for cls in reversed(_classes):
85+
bpy.utils.unregister_class(cls)

stage/ui/n_panel.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ def draw(self, context):
4646
col.operator("stage.studio_remove", icon='REMOVE', text="")
4747
col.separator()
4848
col.operator("stage.studio_duplicate", icon='DUPLICATE', text="")
49+
col.menu("STAGE_MT_templates", icon='PRESET', text="")
4950
col.separator()
5051
op_up = col.operator("stage.studio_move", icon='TRIA_UP', text="")
5152
op_up.direction = 'UP'

tests/blender/test_templates.py

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
"""Tests for starter templates — v1.0."""
2+
3+
from __future__ import annotations
4+
5+
import bpy
6+
7+
from stage.core.templates import TEMPLATES, apply_template_to_studio
8+
9+
10+
def _fresh_scene(name: str = "stage_template_test"):
11+
if name in bpy.data.scenes:
12+
bpy.data.scenes.remove(bpy.data.scenes[name], do_unlink=True)
13+
return bpy.data.scenes.new(name)
14+
15+
16+
def _new_studio(scene, name: str = "Test"):
17+
s = scene.stage_data.studios.add()
18+
s.name = name
19+
s.uuid = f"tpl-test-{name}"
20+
return s
21+
22+
23+
# --- TEMPLATES dict --------------------------------------------------------
24+
25+
26+
def test_templates_has_three_entries():
27+
"""v1.0 ships with the documented three render-settings templates."""
28+
assert "cycles_quality" in TEMPLATES
29+
assert "eevee_preview" in TEMPLATES
30+
assert "print_4k" in TEMPLATES
31+
32+
33+
def test_template_required_fields():
34+
"""Every template has the keys apply_template_to_studio relies on."""
35+
required = {"display_name", "engine", "render_settings"}
36+
for tid, t in TEMPLATES.items():
37+
missing = required - set(t)
38+
assert not missing, f"{tid} missing keys: {missing}"
39+
40+
41+
# --- apply_template_to_studio ----------------------------------------------
42+
43+
44+
def test_apply_cycles_quality_sets_render_facet():
45+
scene = _fresh_scene()
46+
studio = _new_studio(scene)
47+
48+
apply_template_to_studio(studio, TEMPLATES["cycles_quality"])
49+
50+
f = studio.facet_render
51+
assert f.captured is True
52+
assert f.engine == "CYCLES"
53+
assert f.resolution_x == 1920
54+
assert f.resolution_y == 1080
55+
# Engine-specific bag populated with each render_settings entry
56+
paths = {e.data_path for e in f.settings}
57+
assert "cycles.samples" in paths
58+
assert "cycles.use_denoising" in paths
59+
assert "cycles.max_bounces" in paths
60+
61+
62+
def test_apply_eevee_preview_sets_engine():
63+
scene = _fresh_scene()
64+
studio = _new_studio(scene)
65+
apply_template_to_studio(studio, TEMPLATES["eevee_preview"])
66+
assert studio.facet_render.engine == "BLENDER_EEVEE"
67+
assert studio.facet_render.resolution_x == 1280
68+
69+
70+
def test_apply_print_4k_resolution():
71+
scene = _fresh_scene()
72+
studio = _new_studio(scene)
73+
apply_template_to_studio(studio, TEMPLATES["print_4k"])
74+
assert studio.facet_render.resolution_x == 3840
75+
assert studio.facet_render.resolution_y == 2160
76+
77+
78+
def test_apply_template_replaces_settings_bag():
79+
"""Applying a template clears any existing settings entries first."""
80+
scene = _fresh_scene()
81+
studio = _new_studio(scene)
82+
83+
# Pre-populate with stale data
84+
pre = studio.facet_render.settings.add()
85+
pre.data_path = "stale.path"
86+
pre.value_repr = "999"
87+
pre.value_type = 'INT'
88+
89+
apply_template_to_studio(studio, TEMPLATES["cycles_quality"])
90+
91+
paths = {e.data_path for e in studio.facet_render.settings}
92+
assert "stale.path" not in paths, "stale entries should be cleared"
93+
assert "cycles.samples" in paths
94+
95+
96+
# --- operator integration --------------------------------------------------
97+
98+
99+
def test_operator_creates_studio_with_template_settings():
100+
scene = _fresh_scene()
101+
bpy.context.window.scene = scene
102+
103+
with bpy.context.temp_override(scene=scene):
104+
result = bpy.ops.stage.studio_add_from_template(template_id="cycles_quality")
105+
106+
assert result == {'FINISHED'}
107+
studios = scene.stage_data.studios
108+
assert len(studios) == 1
109+
s = studios[0]
110+
assert s.name == "Cycles Quality"
111+
assert s.facet_render.engine == "CYCLES"
112+
assert s.facet_render.resolution_x == 1920
113+
114+
115+
def test_operator_unknown_template_cancelled():
116+
scene = _fresh_scene()
117+
bpy.context.window.scene = scene
118+
119+
raised = False
120+
try:
121+
with bpy.context.temp_override(scene=scene):
122+
bpy.ops.stage.studio_add_from_template(template_id="not_a_real_template")
123+
except RuntimeError:
124+
# bpy.ops raises when the operator reports {'ERROR'} — expected.
125+
raised = True
126+
127+
assert raised, "expected RuntimeError on unknown template"
128+
assert len(scene.stage_data.studios) == 0

0 commit comments

Comments
 (0)