Skip to content

Commit 021617a

Browse files
committed
fix queue overwrite + pause toggle + resolved-path hint
Three fixes around the render queue UX, all driven by a stress test against the Studio data the user actually had: 1. The "directory-only" output_override bug Picking a folder via the Output Path browse button stored a value like "/Users/me/Renders/" — a directory, no filename. The resolver used it as-is, so Blender treated the trailing slash as a folder and generated the filename itself (the .blend's name). Every Studio in a queue overwrote the same file. Fix: detect directory-like overrides (no {tokens} AND ends with / or has no extension) and join with a guaranteed-distinct fallback filename "{studio}_{frame:04d}.{ext}". So /r/ with Studios Hero, Wide, Detail now resolves to /r/Hero_0001.png, /r/Wide_0001.png, /r/Detail_0001.png — confirmed via MCP stress test against the user's real prefs. Tokens-bearing overrides ("/r/{studio}.{ext}") and explicit-file overrides ("/r/output.png") are still used as-is. 2. Resolved output path shown in the Stage panel New "→ <path>" hint under the Output Path field, computed via resolve_output_path(). Lets the user see where renders WILL land before they hit Render or Queue — would have caught the directory bug instantly. Wrapped in try/except so a resolver hiccup never takes down the whole panel draw. 3. Pause Render Queue toggle New StagePreferences.queue_paused BoolProperty + Pause/Auto-Process toggle button at the top of the queue panel. When paused, queue.monitor._tick() reaps finished workers and refreshes the UI but skips spawn_next() — the user must use Start Worker to process one job at a time, or untick to resume auto-processing. Addresses the "queueing auto-starts" complaint without changing the default behaviour. 5 new tests in test_render_path_shared.py: - directory_override_uses_studio_frame_filename - directory_override_without_trailing_slash_still_treated_as_dir - directory_override_distinct_per_studio (regression for the queue bug) - override_with_extension_used_as_is - override_with_tokens_used_as_is Total suite: 167/168 (one pre-existing dirty-state reload flake).
1 parent 41bfc81 commit 021617a

6 files changed

Lines changed: 182 additions & 6 deletions

File tree

stage/core/render.py

Lines changed: 55 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,13 +46,67 @@
4646
}
4747

4848

49+
def _looks_like_directory(path: str) -> bool:
50+
"""True if this looks like a folder path the user picked via the browse
51+
button rather than a full filename pattern.
52+
53+
Heuristic: no {tokens} AND (ends with a path separator OR has no extension).
54+
A path with tokens is assumed intentional and used as-is. A path with an
55+
extension and no tokens is a single-file output (one Studio scenario,
56+
user accepts the overwrite if they queue multiple).
57+
"""
58+
if not path:
59+
return False
60+
if "{" in path and "}" in path:
61+
return False
62+
if path.endswith(("/", "\\")):
63+
return True
64+
# No separator at end — check if there's a file extension on the leaf
65+
leaf = path.rsplit("/", 1)[-1].rsplit("\\", 1)[-1]
66+
return "." not in leaf
67+
68+
69+
# Filename used when the user picks a folder via the browse button. Both
70+
# {studio} and {frame} are included so distinct Studios queued together
71+
# produce distinct files (the bug that motivated this whole helper) and
72+
# multi-frame animations don't overwrite themselves.
73+
_DIR_FALLBACK_FILENAME = "{studio}_{frame:04d}.{ext}"
74+
75+
76+
def _join_dir_and_filename(directory: str, filename: str) -> str:
77+
"""Concatenate, normalising the separator between them. Preserves the
78+
leading '//' blend-relative prefix on the directory."""
79+
if directory.endswith(("/", "\\")):
80+
return directory + filename
81+
return directory + "/" + filename
82+
83+
4984
def resolve_output_path(scene, studio, default_pattern: str, *, frozen_now=None) -> str:
5085
"""Pure function: compute the expanded output path for this Studio.
5186
87+
Behaviour:
88+
- Empty override: use default_pattern
89+
- Override is a *directory* (no tokens, ends with / or no extension):
90+
combine override (directory) with the default_pattern's filename
91+
portion, so the user can pick a folder via the browse button
92+
and still get distinct per-Studio filenames.
93+
- Override has tokens or looks like a file: use override as-is.
94+
5295
No side effects on the scene. Use this when you need to know where a
5396
render WOULD go without actually running the render.
5497
"""
55-
template = studio.output_override or default_pattern
98+
override = studio.output_override
99+
if not override:
100+
template = default_pattern
101+
elif _looks_like_directory(override):
102+
# default_pattern's filename portion isn't safe to reuse here —
103+
# if it's just "{frame}.{ext}" then every Studio collides on the
104+
# same file. Use a guaranteed-distinct {studio}_{frame:04d}.{ext}
105+
# template instead.
106+
template = _join_dir_and_filename(override, _DIR_FALLBACK_FILENAME)
107+
else:
108+
template = override
109+
56110
ctx = build_default_context(
57111
studio_name=studio.name,
58112
blend_path=bpy.data.filepath,

stage/prefs.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,18 @@ class StagePreferences(AddonPreferences):
9292
# forward across versions.
9393
user_templates: CollectionProperty(type=UserTemplate)
9494

95+
# Render queue — when paused, the monitor timer doesn't auto-spawn the
96+
# next worker even if PENDING jobs exist. User has to hit "Start Worker"
97+
# to kick a single job, or untick Pause to resume auto-processing.
98+
queue_paused: BoolProperty(
99+
name="Pause Render Queue",
100+
description=(
101+
"Don't auto-start workers when jobs are queued. Use Start Worker "
102+
"to process one job manually, or untick to resume auto-processing"
103+
),
104+
default=False,
105+
)
106+
95107
# Logging
96108
log_level: EnumProperty(
97109
name="Log Level",

stage/queue/monitor.py

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,18 @@ def _tag_redraw() -> None:
3535
area.tag_redraw()
3636

3737

38+
def _is_queue_paused() -> bool:
39+
"""True if the user has ticked Pause Render Queue in addon prefs.
40+
Returns False on any lookup error (no prefs entry, etc.) so the queue
41+
keeps running rather than silently stalling."""
42+
try:
43+
from ..prefs import get_prefs
44+
prefs = get_prefs(bpy.context)
45+
return bool(prefs and prefs.queue_paused)
46+
except Exception:
47+
return False
48+
49+
3850
def _tick() -> float:
3951
"""Single timer iteration. Returns POLL_INTERVAL_SECONDS so the timer
4052
fires again, or None to stop."""
@@ -43,10 +55,13 @@ def _tick() -> float:
4355
if finished_job_id is not None:
4456
_log.info("Queue: job %d finished", finished_job_id)
4557

46-
# Spawn next pending job if no worker is currently busy
47-
spawned = queue_worker.spawn_next()
48-
if spawned is not None:
49-
_log.info("Queue: spawned worker for job %d", spawned)
58+
# Auto-spawn unless the user has paused the queue. Pause respects
59+
# the existing worker — a job that's already running keeps going;
60+
# we just don't pick up the next one.
61+
if not _is_queue_paused():
62+
spawned = queue_worker.spawn_next()
63+
if spawned is not None:
64+
_log.info("Queue: spawned worker for job %d", spawned)
5065

5166
_tag_redraw()
5267
except Exception as e:

stage/ui/n_panel.py

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -114,10 +114,26 @@ def draw(self, context):
114114
icon='GROUP',
115115
)
116116

117-
# Output Path — label on its own line so the field gets full width
117+
# Output Path — label on its own line so the field gets full width.
118+
# Below the field we show the resolved path (after template
119+
# expansion + directory-fallback rules) so the user can see
120+
# exactly where a render would land before they click Render
121+
# or Queue. Empty override = falls back to the addon-prefs
122+
# default pattern shown above.
118123
col = details.column(align=True)
119124
col.label(text="Output Path:")
120125
col.prop(active, "output_override", text="")
126+
try:
127+
from ..core.render import resolve_output_path
128+
from ..prefs import get_default_output_pattern
129+
resolved = resolve_output_path(
130+
context.scene, active,
131+
get_default_output_pattern(context),
132+
)
133+
hint = col.row()
134+
hint.label(text=f"→ {resolved}", icon='FILE_TICK')
135+
except Exception:
136+
pass
121137

122138
# Facet capture toggles — what this Studio remembers
123139
fcol = details.column(align=True)

stage/ui/queue_panel.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
import bpy
1818
from bpy.types import Panel
1919

20+
from ..prefs import get_prefs
2021
from ..queue import db as queue_db
2122
from ..queue import worker as queue_worker
2223

@@ -80,6 +81,20 @@ def draw(self, context):
8081
# Toolbar — control
8182
col = layout.column(align=True)
8283
col.label(text="Control:")
84+
85+
# Pause toggle drives auto-spawn behaviour in queue.monitor.
86+
# When paused the user must hit Start Worker to process one job.
87+
prefs = get_prefs(context)
88+
if prefs is not None:
89+
paused = prefs.queue_paused
90+
row = col.row(align=True)
91+
row.prop(
92+
prefs, "queue_paused",
93+
text="Paused" if paused else "Auto-Process",
94+
icon='PAUSE' if paused else 'PLAY',
95+
toggle=True,
96+
)
97+
8398
row = col.row(align=True)
8499
row.enabled = queue_worker.is_worker_alive()
85100
row.operator("stage.queue_cancel_active", icon='CANCEL')

tests/blender/test_render_path_shared.py

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,70 @@ def test_resolve_output_path_preserves_blender_relative_prefix():
7979
assert "Hero" in path
8080

8181

82+
# --- directory-like override fallback (the queue overwrite bug) ----------
83+
84+
85+
def test_directory_override_uses_studio_frame_filename():
86+
"""User picks a folder — result is <folder>/<studio>_<frame>.<ext>
87+
so distinct Studios produce distinct files (no shared filename)."""
88+
scene = _fresh_scene()
89+
studio = _make_studio(scene, "Hero", output_override="/Users/me/Renders/")
90+
91+
path = resolve_output_path(
92+
scene, studio,
93+
default_pattern="{blendname}/{studio}/{frame}.{ext}",
94+
)
95+
assert path == "/Users/me/Renders/Hero_0001.png", f"unexpected: {path}"
96+
97+
98+
def test_directory_override_without_trailing_slash_still_treated_as_dir():
99+
"""A leaf with no extension reads as a folder, even without trailing slash."""
100+
scene = _fresh_scene()
101+
studio = _make_studio(scene, "Hero", output_override="/Users/me/Renders")
102+
103+
path = resolve_output_path(
104+
scene, studio, default_pattern="ignored",
105+
)
106+
assert path == "/Users/me/Renders/Hero_0001.png", f"unexpected: {path}"
107+
108+
109+
def test_directory_override_distinct_per_studio():
110+
"""Two Studios with the same folder override must resolve to distinct files."""
111+
scene = _fresh_scene()
112+
a = _make_studio(scene, "Hero", output_override="/r/")
113+
b = _make_studio(scene, "Wide", output_override="/r/")
114+
115+
pa = resolve_output_path(scene, a, default_pattern="ignored")
116+
pb = resolve_output_path(scene, b, default_pattern="ignored")
117+
assert pa != pb, "distinct studios must produce distinct output paths"
118+
assert "Hero" in pa
119+
assert "Wide" in pb
120+
121+
122+
def test_override_with_extension_used_as_is():
123+
"""`.png` leaf means the user is intentionally writing one file —
124+
don't second-guess them."""
125+
scene = _fresh_scene()
126+
studio = _make_studio(scene, "Hero", output_override="/r/output.png")
127+
128+
path = resolve_output_path(
129+
scene, studio, default_pattern="{studio}/{frame}.{ext}",
130+
)
131+
assert path == "/r/output.png", f"expected as-is, got {path}"
132+
133+
134+
def test_override_with_tokens_used_as_is():
135+
"""Override containing {tokens} is intentional — use it directly
136+
even if it doesn't have a file extension."""
137+
scene = _fresh_scene()
138+
studio = _make_studio(
139+
scene, "Hero", output_override="/r/{studio}_{frame:04d}.{ext}",
140+
)
141+
142+
path = resolve_output_path(scene, studio, default_pattern="ignored")
143+
assert path == "/r/Hero_0001.png", f"unexpected expansion: {path}"
144+
145+
82146
def test_foreground_and_subprocess_paths_match():
83147
"""The whole point of extracting core/render.resolve_output_path —
84148
foreground and subprocess MUST produce identical paths for the same

0 commit comments

Comments
 (0)