cleanup previous PR fixing one regression and doing code cleanups

This commit is contained in:
Kovid Goyal 2026-09-16 12:57:51 +05:30
parent 5d58f2f4cc
commit d37f845293
No known key found for this signature in database
GPG key ID: 06BC317B515ACE7C
8 changed files with 224 additions and 84 deletions

View file

@ -828,7 +828,7 @@ prepare_to_render_os_window(
bool was_previously_rendered_with_layers = os_window->needs_layers;
os_window->needs_layers =
(!global_state.supports_framebuffer_srgb || effective_os_window_alpha(os_window) < 1.f || os_window->live_resize.in_progress ||
(background_image_for_os_window(os_window) != NULL) || os_window->has_active_custom_shaders);
(background_image_for_os_window(os_window) != NULL) || os_window->shader_anim.has_active_shaders);
if (TD.screen && os_window->num_tabs && !os_window->has_too_few_tabs) {
if (!os_window->tab_bar_data_updated) {
call_boss(update_tab_bar_data, "K", os_window->id);
@ -945,7 +945,7 @@ prepare_to_render_os_window(
if (blink_has_ceased && !os_window->user_is_idle) {
os_window->user_is_idle = true;
os_window->shader_anim_event_registry |= (1u << SHADER_ANIM_EVENT_USER_IDLE);
} else if (!blink_has_ceased && OPT(cursor_blink_interval) <= 0 && os_window->has_active_custom_shaders) {
} else if (!blink_has_ceased && OPT(cursor_blink_interval) <= 0 && os_window->shader_anim.has_active_shaders) {
// cursor blinking is disabled so collect_cursor_info won't schedule a wakeup for
// this deadline; do it here so user-idle fires on time
set_maximum_wait(OPT(cursor_stop_blinking_after) - time_since_last_activity);
@ -957,18 +957,16 @@ prepare_to_render_os_window(
events |= (1u << SHADER_ANIM_EVENT_TAB_CHANGE) | (1u << SHADER_ANIM_EVENT_WINDOW_FOCUS_IN) | (1u << SHADER_ANIM_EVENT_WINDOW_FOCUS_OUT);
if (*active_window_id && *active_window_id != os_window->last_active_window_id)
events |= (1u << SHADER_ANIM_EVENT_WINDOW_FOCUS_IN) | (1u << SHADER_ANIM_EVENT_WINDOW_FOCUS_OUT);
const bool was_active = os_window->has_active_custom_shaders;
const bool was_animating = os_window->shader_anim_min_step < MONOTONIC_T_MAX;
const bool animation_ended = now >= os_window->shader_anim_next_end_at;
const ShaderAnimState before = os_window->shader_anim;
monotonic_t min_step = update_custom_shader_animations(events, now, os_window);
os_window->shader_anim_event_registry = 0;
if (os_window->has_active_custom_shaders) {
os_window->needs_layers = true;
if (events || min_step < MONOTONIC_T_MAX) needs_render = true;
}
if (was_active != os_window->has_active_custom_shaders || was_animating || animation_ended) needs_render = true;
if (os_window->shader_anim.has_active_shaders) os_window->needs_layers = true;
if (custom_shader_needs_render(&before, &os_window->shader_anim, events, now)) needs_render = true;
// Wake up for the next animation frame and for the moment a duration
// bounded animation expires. Static groups (animation_step 0) have
// min_step == MONOTONIC_T_MAX and so schedule no periodic wakeup.
if (min_step < MONOTONIC_T_MAX) set_maximum_wait(min_step);
if (os_window->shader_anim_next_end_at < MONOTONIC_T_MAX) set_maximum_wait(os_window->shader_anim_next_end_at - now);
if (os_window->shader_anim.next_end_at < MONOTONIC_T_MAX) set_maximum_wait(os_window->shader_anim.next_end_at - now);
}
return needs_render || was_previously_rendered_with_layers != os_window->needs_layers;
}

View file

@ -532,6 +532,8 @@ def compile_program(
) -> int:
pass
def custom_shader_needs_render(before: Tuple[bool, int, int], after: Tuple[bool, int, int], event_mask: int, now: int) -> bool: ...
def simulate_custom_shader_render_ticks(num_ticks: int, event_mask: int = 0, initialize: bool = False) -> list[bool]: ...
def set_os_window_chrome(os_window_id: int) -> bool:
pass

View file

@ -501,6 +501,24 @@ init_cell_program(void) {
bind_shader_globals_to_current_context();
}
// Non-animated, non-attached groups are active from the moment the pipeline
// loads; animated groups start inactive and attached groups inherit the state
// of their predecessor.
static bool
group_is_initially_active(const CustomShaderGroup *cg, bool predecessor_is_active) {
return cg->attached ? predecessor_is_active : (cg->animation_start_events == 0);
}
static bool
pipeline_has_initially_active_group(void) {
bool prev = false;
for (size_t i = 0; i < custom_shaders.end.num_groups; i++) {
prev = group_is_initially_active(custom_shaders.end.groups + i, prev);
if (prev) return true;
}
return false;
}
static void
init_custom_programs(void) {
custom_shaders.count = 0;
@ -541,33 +559,9 @@ init_custom_programs(void) {
if (global_state.layers_render_texture.texture_b_fbo_id) free_framebuffer(&global_state.layers_render_texture.texture_b_fbo_id);
}
// persist is per-window and freed lazily in start_os_window_rendering
// Non-animated, non-attached groups are always active; animated groups start inactive.
// Attached groups inherit their initial active state from their predecessor.
bool any_initially_active = false;
{
bool prev_init = false;
for (size_t i = 0; i < custom_shaders.end.num_groups; i++) {
const CustomShaderGroup *cg = &custom_shaders.end.groups[i];
bool this_init = cg->attached ? prev_init : (cg->animation_start_events == 0);
if (this_init) any_initially_active = true;
prev_init = this_init;
}
}
bool initially_active = custom_shaders.count > 0 && any_initially_active;
for (size_t w = 0; w < global_state.num_os_windows; w++) {
OSWindow *osw = global_state.os_windows + w;
zero_at_ptr_count(osw->shader_group_anim, custom_shaders.end.num_groups);
// Initialize active=true for attached groups that follow always-active predecessors
bool prev_init = false;
for (size_t i = 0; i < custom_shaders.end.num_groups; i++) {
const CustomShaderGroup *cg = &custom_shaders.end.groups[i];
bool this_init = cg->attached ? prev_init : (cg->animation_start_events == 0);
if (cg->attached && this_init) osw->shader_group_anim[i].active = true;
prev_init = this_init;
}
osw->has_active_custom_shaders = initially_active;
osw->shader_anim_min_step = MONOTONIC_T_MAX;
osw->shader_anim_next_end_at = MONOTONIC_T_MAX;
init_shader_animation_state(osw);
// Synthesize focus events for windows that already have focus so that
// animations triggered by os-window-focus-in start on the first render
// after shader compilation (the focus-in event fired before shaders were
@ -579,7 +573,31 @@ init_custom_programs(void) {
custom_shaders.count,
custom_shaders.end.num_groups,
custom_shaders.end.textures,
initially_active);
custom_shaders.count > 0 && pipeline_has_initially_active_group());
}
// Reset the per-OS-window animation state to what it should be immediately
// after the current pipeline was loaded. Called both when the pipeline changes
// and when an OS window is created, so that the MONOTONIC_T_MAX sentinels in
// ShaderAnimState are never left at their zero initialized values -- a zero
// min_step/next_end_at reads as "an animation is running and its deadline has
// already passed", which would make the renderer redraw on every single tick.
void
init_shader_animation_state(OSWindow *osw) {
zero_at_ptr_count(osw->shader_group_anim, custom_shaders.end.num_groups);
bool prev_init = false, any_initially_active = false;
for (size_t i = 0; i < custom_shaders.end.num_groups; i++) {
const CustomShaderGroup *cg = &custom_shaders.end.groups[i];
const bool this_init = group_is_initially_active(cg, prev_init);
// Only attached groups are tracked by the state machine when initially
// active; always-active non-attached groups never enter it at all.
if (cg->attached && this_init) osw->shader_group_anim[i].active = true;
if (this_init) any_initially_active = true;
prev_init = this_init;
}
osw->shader_anim.has_active_shaders = custom_shaders.count > 0 && any_initially_active;
osw->shader_anim.min_step = MONOTONIC_T_MAX;
osw->shader_anim.next_end_at = MONOTONIC_T_MAX;
}
static const char *
@ -624,12 +642,12 @@ shader_anim_event_mask_str(unsigned mask) {
monotonic_t
update_custom_shader_animations(unsigned event_mask, monotonic_t now, OSWindow *os_window) {
if (!custom_shaders.count) {
os_window->has_active_custom_shaders = false;
os_window->shader_anim = (ShaderAnimState){.min_step = MONOTONIC_T_MAX, .next_end_at = MONOTONIC_T_MAX};
return MONOTONIC_T_MAX;
}
// Fast path: no events and no duration-bounded animation expiring this frame.
// Cached state is still valid — skip group iteration entirely.
if (!event_mask && now < os_window->shader_anim_next_end_at) return os_window->shader_anim_min_step;
if (!event_mask && now < os_window->shader_anim.next_end_at) return os_window->shader_anim.min_step;
// Slow path: update per-group state and recompute cached values in one pass.
CustomShaderPipeline *p = &custom_shaders.end;
@ -694,14 +712,35 @@ update_custom_shader_animations(unsigned event_mask, monotonic_t now, OSWindow *
}
prev_active = this_active;
}
bool prev_has_active = os_window->has_active_custom_shaders;
os_window->has_active_custom_shaders = any_active;
if (prev_has_active != any_active) debug_rendering("has_active_custom_shaders: %d -> %d\n", prev_has_active, any_active);
os_window->shader_anim_min_step = min_step;
os_window->shader_anim_next_end_at = next_end;
bool prev_has_active = os_window->shader_anim.has_active_shaders;
if (prev_has_active != any_active) debug_rendering("has_active_shaders: %d -> %d\n", prev_has_active, any_active);
os_window->shader_anim.has_active_shaders = any_active;
os_window->shader_anim.min_step = min_step;
os_window->shader_anim.next_end_at = next_end;
return min_step;
}
// Decide whether the custom shader layer needs a frame drawn this tick, given
// the cached state from the previous tick and the state just recomputed by
// update_custom_shader_animations(). Kept separate from the renderer so it can
// be unit tested -- see test_custom_shader_redraw_logic.
bool
custom_shader_needs_render(const ShaderAnimState *before, const ShaderAnimState *after, unsigned event_mask, monotonic_t now) {
// Groups turned on or off: draw once so the change becomes visible. This is
// what produces the final frame that clears a finished effect.
if (before->has_active_shaders != after->has_active_shaders) return true;
// A duration bounded animation expires on this tick.
if (now >= before->next_end_at) return true;
// Something was animating last tick, so it either wants its next frame or
// has just stopped and wants one last one.
if (before->min_step < MONOTONIC_T_MAX) return true;
if (!after->has_active_shaders) return false;
// Animated groups need periodic frames. Static groups (animation_step 0)
// only need one when an event may have changed what they draw; everything
// else that changes the screen requests a redraw through other paths.
return event_mask != 0 || after->min_step < MONOTONIC_T_MAX;
}
void
bind_shader_globals_to_current_context(void) {
if (shader_globals_vao_idx == -1) return;
@ -2750,7 +2789,7 @@ stop_os_window_rendering(OSWindow *os_window, Tab *tab, Window *active_window, m
if (os_window->needs_layers) {
float sx = global_state.layers_render_texture.width > 0 ? (float)os_window->viewport_width / (float)global_state.layers_render_texture.width : 1.f;
float sy = global_state.layers_render_texture.height > 0 ? (float)os_window->viewport_height / (float)global_state.layers_render_texture.height : 1.f;
if (custom_shaders.end.active && os_window->has_active_custom_shaders) {
if (custom_shaders.end.active && os_window->shader_anim.has_active_shaders) {
// debug_rendering("Custom end shader running (groups=%zu)\n", custom_shaders.end.num_groups);
restore_viewport();
if (os_window->live_resize.in_progress)
@ -3155,6 +3194,55 @@ sprite_map_set_limits(PyObject UNUSED *self, PyObject *args) {
Py_RETURN_NONE;
}
// Test only. Wraps custom_shader_needs_render() so the redraw decision can be
// exercised from the Python test suite without a GPU context. Each state is a
// (has_active_shaders, min_step, next_end_at) tuple.
static bool
parse_shader_anim_state(PyObject *obj, ShaderAnimState *ans) {
int has_active;
long long min_step, next_end_at;
if (!PyArg_ParseTuple(obj, "pLL", &has_active, &min_step, &next_end_at)) return false;
*ans = (ShaderAnimState){.has_active_shaders = has_active, .min_step = min_step, .next_end_at = next_end_at};
return true;
}
static PyObject *
pycustom_shader_needs_render(PyObject *self UNUSED, PyObject *args) {
PyObject *b, *a;
unsigned int event_mask;
long long now;
ShaderAnimState before, after;
if (!PyArg_ParseTuple(args, "O!O!IL", &PyTuple_Type, &b, &PyTuple_Type, &a, &event_mask, &now)) return NULL;
if (!parse_shader_anim_state(b, &before) || !parse_shader_anim_state(a, &after)) return NULL;
if (custom_shader_needs_render(&before, &after, event_mask, now)) Py_RETURN_TRUE;
Py_RETURN_FALSE;
}
// Test only. Runs the renderer's per-frame custom shader bookkeeping (the same
// sequence as prepare_to_render_os_window) against a freshly allocated, zero
// initialized OSWindow and returns whether each tick asked for a redraw. Used
// to assert that an idle OS window with no shader work pending stays idle.
static PyObject *
pysimulate_custom_shader_render_ticks(PyObject *self UNUSED, PyObject *args) {
unsigned long num_ticks;
unsigned int event_mask = 0;
int initialize = 0;
if (!PyArg_ParseTuple(args, "k|Ip", &num_ticks, &event_mask, &initialize)) return NULL;
RAII_ALLOC(OSWindow, osw, calloc(1, sizeof(OSWindow)));
if (!osw) return PyErr_NoMemory();
if (initialize) init_shader_animation_state(osw);
RAII_PyObject(ans, PyList_New(0));
if (!ans) return NULL;
monotonic_t now = monotonic();
for (unsigned long i = 0; i < num_ticks; i++, now += ms_to_monotonic_t(10ll)) {
const ShaderAnimState before = osw->shader_anim;
update_custom_shader_animations(event_mask, now, osw);
PyObject *v = custom_shader_needs_render(&before, &osw->shader_anim, event_mask, now) ? Py_True : Py_False;
if (PyList_Append(ans, v) != 0) return NULL;
}
return Py_NewRef(ans);
}
#define M(name, arg_type) {#name, (PyCFunction)name, arg_type, NULL}
#define MW(name, arg_type) {#name, (PyCFunction)py##name, arg_type, NULL}
static PyMethodDef module_methods[] = {
@ -3167,6 +3255,8 @@ static PyMethodDef module_methods[] = {
MW(unmap_vao_buffer, METH_VARARGS),
MW(bind_program, METH_O),
MW(unbind_program, METH_NOARGS),
MW(custom_shader_needs_render, METH_VARARGS),
MW(simulate_custom_shader_render_ticks, METH_VARARGS),
{NULL, NULL, 0, NULL} /* Sentinel */
};

View file

@ -1,4 +1,6 @@
startgroup
# static effect, redraw only when the active window changes
animation_step 0
var float HIGHLIGHT_INTENSITY = 0
var float BORDER_WIDTH = 0
shaders focus-highlight

View file

@ -184,10 +184,14 @@ class LoadShaderPrograms:
text_old_gamma: bool = False
custom_shaders: tuple[str, ...] = ()
force_recompile_of_custom_shaders: bool = False
last_built_custom_shaders: dict[int, Any] = {}
opts: Options | None = None
def __init__(self) -> None:
# Per-instance because it is mutable: the sources most recently compiled
# into each program, used to skip redundant recompiles.
self.last_built_custom_shaders: dict[int, tuple[str, str, dict[str, Any]]] = {}
def get_options(self) -> Options:
try:
return self.opts or get_options()
@ -270,11 +274,17 @@ class LoadShaderPrograms:
continue
pmap.setdefault(d['slot'], []).append(d)
def disable(prog: int) -> None:
# Forget the cached sources as well, otherwise re-enabling the same
# shader later is a no-op because the sources compare equal while the
# program itself is empty.
compile_program(prog, (), (), {}, allow_recompile)
self.last_built_custom_shaders.pop(prog, None)
def do(prog: int, slot: str) -> None:
slot_pipelines = pmap.get(slot)
if not slot_pipelines:
compile_program(prog, (), (), {}, allow_recompile)
self.last_built_custom_shaders.pop(prog, None)
disable(prog)
else:
try:
pipeline = merge_pipelines(slot_pipelines)
@ -283,8 +293,7 @@ class LoadShaderPrograms:
# print(frag, file=open('/tmp/sample.frag', 'w'))
except Exception as e:
log_error(f'Failed to build custom shader for slot {slot} with error: {e}')
compile_program(prog, (), (), {}, allow_recompile)
self.last_built_custom_shaders.pop(prog, None)
disable(prog)
else:
try:
if self.last_built_custom_shaders.get(prog) != (vert, frag, metadata):
@ -292,8 +301,7 @@ class LoadShaderPrograms:
self.last_built_custom_shaders[prog] = vert, frag, metadata
except Exception as e:
log_error(f'Failed to load custom shader for slot {slot} with error: {e}')
compile_program(prog, (), (), {}, allow_recompile)
self.last_built_custom_shaders.pop(prog, None)
disable(prog)
do(CUSTOM_END_PROGRAM, 'end')
compile_program(-2, (), (), {}) # initialize programs
@ -1304,7 +1312,10 @@ def parse_pipeline_definition(lines: Iterable[str], pipeline_name: str, pipeline
case 'animation_step':
if len(parts) < 2:
raise ValueError('animation_step requires a millisecond value')
current_group['animation_step'] = int(parts[1]) * 1_000_000
step_ms = int(parts[1])
if step_ms < 0:
raise ValueError(f'animation_step must be non-negative, not: {step_ms}')
current_group['animation_step'] = step_ms * 1_000_000
case 'animation_stop':
val = ''.join(parts[1:]) if len(parts) > 1 else 'never'
if val == 'never':

View file

@ -313,6 +313,7 @@ add_os_window(void) {
ans->tab_bar_render_data.vao_idx = create_cell_vao();
ans->background_opacity.alpha = OPT(background_opacity);
ans->created_at = monotonic();
init_shader_animation_state(ans);
END_WITH_OS_WINDOW_REFS
return ans;
}

View file

@ -484,6 +484,17 @@ typedef struct BackgroundImageRenderSettings {
#define MAX_CUSTOM_SHADER_GROUPS 16
// Summary of the custom shader animation state machine, cached once per frame
// so the renderer can decide whether a new frame is needed without walking the
// pipeline. Both deadlines use MONOTONIC_T_MAX as their "nothing to do"
// sentinel, so this must never be left zero initialized -- see
// init_shader_animation_state().
typedef struct ShaderAnimState {
bool has_active_shaders; // at least one group will draw this frame
monotonic_t min_step; // smallest animation_step across active groups, MONOTONIC_T_MAX if none want periodic redraws
monotonic_t next_end_at; // earliest expiry of a duration bounded active animation, MONOTONIC_T_MAX if none
} ShaderAnimState;
typedef enum {
SHADER_ANIM_EVENT_POINTER_LEFT_BUTTON_PRESS,
SHADER_ANIM_EVENT_OS_WINDOW_FOCUS_IN,
@ -575,9 +586,7 @@ typedef struct OSWindow {
bool active;
monotonic_t started_at;
} shader_group_anim[MAX_CUSTOM_SHADER_GROUPS];
bool has_active_custom_shaders;
monotonic_t shader_anim_min_step; // cached min animation_step across active animated groups
monotonic_t shader_anim_next_end_at; // earliest expiry of a duration-bounded active animation
ShaderAnimState shader_anim;
} OSWindow;
static inline float
@ -769,6 +778,8 @@ void dispatch_buffered_keys(Window *w);
bool screen_needs_rendering_in_layers(OSWindow *os_window, Window *w, Screen *screen);
void setup_os_window_for_rendering(OSWindow *, Tab *, Window *, bool, monotonic_t);
monotonic_t update_custom_shader_animations(unsigned event_mask, monotonic_t now, OSWindow *os_window);
void init_shader_animation_state(OSWindow *os_window);
bool custom_shader_needs_render(const ShaderAnimState *before, const ShaderAnimState *after, unsigned event_mask, monotonic_t now);
void swap_window_buffers(OSWindow *w);
void take_screenshot_of_rectangular_region(OSWindow *os_window, Region region, unsigned char *dst_buf, unsigned *thumb_w, unsigned *thumb_h, bool no_scaling);
bool current_framebuffer_is_ok(void);

View file

@ -35,41 +35,66 @@ _SUPPORT_SHADER_NAMES = frozenset(('types', 'pipeline'))
class TestSlang(BaseTest):
def test_inactive_window_tint_pipeline(self):
if not shutil.which(slangc()[0]):
self.skipTest(f'slangc ({slangc()[0]}) not found in PATH')
def test_animation_step_parsing(self):
def step(val):
return parse_pipeline_definition(['startgroup', f'animation_step {val}', 'shaders focus-highlight', 'endgroup'], 'test')['groups'][0][
'animation_step'
]
pipeline = parse_pipeline_definition(
"""
startgroup
animation_step 0
var float HIGHLIGHT_INTENSITY = 0
var float BORDER_WIDTH = 0
var float INACTIVE_DIM = 0.74
var float3 INACTIVE_DIM_COLOR = float3(0.2158605)
var bool DIM_CENTRAL_AREA_ONLY = true
var bool INCLUDE_WINDOW_PADDING = true
shaders focus-highlight
endgroup
""".splitlines(),
'dim-gray',
)
with tempfile.TemporaryDirectory() as cache_dir:
clear_caches()
try:
vertex, fragment, metadata = build_custom_shader_pipeline_glsl(pipeline, cache_dir=cache_dir)
finally:
clear_caches()
self.assertTrue(vertex)
self.assertTrue(fragment)
self.ae(metadata['pipeline']['groups'][0]['animation_step'], 0)
# 0 means "static effect, no periodic redraws" and must survive as 0
self.ae(step(0), 0)
self.ae(step(16), 16_000_000)
self.assertRaises(ValueError, step, -1)
def test_custom_shader_redraw_logic(self):
from kitty.fast_data_types import custom_shader_needs_render, simulate_custom_shader_render_ticks
NEVER = 2**63 - 1 # MONOTONIC_T_MAX, the "nothing scheduled" sentinel
STEP = 50_000_000 # an animated group redrawing every 50ms
NOW = 1_000_000_000
def needs_render(before, after, events=0, now=NOW):
return custom_shader_needs_render(before, after, events, now)
idle = (False, NEVER, NEVER)
static = (True, NEVER, NEVER)
animating = (True, STEP, NEVER)
# Nothing active and nothing pending: the shader layer must not ask for frames.
self.assertFalse(needs_render(idle, idle))
self.assertFalse(needs_render(idle, idle, events=1))
# A static group only redraws when an event may have changed its output.
self.assertFalse(needs_render(static, static))
self.assertTrue(needs_render(static, static, events=1))
# An animated group redraws every tick...
self.assertTrue(needs_render(animating, animating))
# ...and gets one final frame after it stops, so the effect is cleared.
self.assertTrue(needs_render(animating, static))
self.assertTrue(needs_render(animating, idle))
# Groups turning on or off is always a visible change.
self.assertTrue(needs_render(idle, static))
self.assertTrue(needs_render(static, idle))
# The tick on which a duration bounded animation expires needs a frame,
# earlier ticks do not.
self.assertTrue(needs_render((True, NEVER, NOW), static))
self.assertTrue(needs_render((True, NEVER, NOW - 1), static))
self.assertFalse(needs_render((True, NEVER, NOW + 1), static))
# A window with no custom shaders configured must settle into asking for
# no frames at all, otherwise an idle kitty redraws forever. Test both a
# zero initialized window and one initialized the way add_os_window()
# does it, since zero is not the MONOTONIC_T_MAX sentinel.
self.ae(simulate_custom_shader_render_ticks(8, 0, True), [False] * 8)
self.ae(simulate_custom_shader_render_ticks(8)[1:], [False] * 7)
# Even a steady stream of shader events must not produce frames when
# there are no shaders to draw.
self.ae(simulate_custom_shader_render_ticks(8, 0xFF, True), [False] * 8)
def test_custom_shader_reenable(self):
from kitty.fast_data_types import CUSTOM_END_PROGRAM
from kitty.options.types import defaults
loader = LoadShaderPrograms()
loader.last_built_custom_shaders = {}
pipeline = parse_pipeline_definition(['startgroup', 'animation_step 0', 'shaders focus-highlight', 'endgroup'], 'test')
self.ae(pipeline['groups'][0]['animation_step'], 0)
with (