Skip to content

Commit f26caac

Browse files
Implement CSS animation play state (#665) (#670)
1 parent 395596f commit f26caac

13 files changed

Lines changed: 445 additions & 16 deletions
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
# CSS animation play-state source validation
2+
3+
Issue #665 adds bounded `animation-play-state` behavior to WebScene's existing
4+
single-animation opacity, `rotate()`, and compatible foreground-filter keyframe
5+
runtime under #242. The first longhand list item and first shorthand animation
6+
accept `running` or `paused`. `animationPlayState`, the kebab-case CSSOM name,
7+
`getPropertyValue("animation-play-state")`, shorthand serialization, mutation,
8+
and clearing expose the retained value without an ABI or AppScene change.
9+
10+
## Held timeline contract
11+
12+
Play state remains outside keyframe identity. A running-to-paused mutation stores
13+
one bounded host timestamp on the node's existing animation runtime. Opacity,
14+
rotation, and filter tracks sample that held timestamp, so positive delay remains
15+
in its fill-aware before phase and negative delay immediately exposes its active
16+
or terminal sample. No timer or background clock advances the hold.
17+
18+
On resume, unchanged active track origins move forward once by the elapsed held
19+
host duration. Their effective progress therefore continues from the held sample
20+
without rebuilding keyframes or restarting the animation. If supported keyframe
21+
identity changes while paused, changed tracks retain the established restart
22+
behavior while unchanged active tracks preserve their held progress.
23+
24+
A terminal sample reached while paused remains held and cannot dispatch
25+
`animationend`; resume performs the existing finite completion path. The shared
26+
end latch still dispatches at most once. Completed fill remains settled. Removal,
27+
node detach, document replacement, navigation, and engine teardown discard the
28+
node-owned hold with the established animation runtime lifecycle.
29+
30+
## Scheduling and bounds
31+
32+
- Paused keyframes are excluded from `has_active_animations()`, including delay
33+
and active phases. A play-state mutation can publish its held sample once, but
34+
does not sustain host-frame demand.
35+
- Resume uses the existing visibility and paint-area gates. Opacity, rotation,
36+
and filters remain paint-only and do not add selector, cascade, layout, scene
37+
node, queue, or per-frame allocation work.
38+
- The runtime still configures one animation and the first coordinated-list item.
39+
Existing duration, iteration, keyframe-stop, and filter-list bounds remain.
40+
- Zero-duration/zero-count behavior, multiple animations, translate/scale,
41+
pseudo-elements, additional animation events, and Web Animations playback APIs
42+
remain deferred.
43+
44+
## Evidence
45+
46+
- `test_animation_play_state_holds_and_resumes_host_time` covers shorthand and
47+
longhand metadata, CSSOM mutation/clear, positive and negative delay, reverse
48+
opacity/rotation/filter samples, hold stability, resumed continuity, terminal
49+
fill, exactly-once `animationend`, detach, and settled frame demand.
50+
- `tests/WebPlatformSubset/contracts/css-animation-play-state.html` is a bounded
51+
WPT-aligned browser contract for the same observable boundaries. Its dedicated
52+
profile selects only that contract.
53+
- Under the fast implementation policy, only `git diff --check` is executed.
54+
Native, browser, package, idle, and unchanged Code OSS acceptance remain
55+
unexecuted.

‎experiments/WebScene.NativeEngine.Probe/native/webscene_css_decoration_values.h‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -187,6 +187,11 @@ bool apply_decoration_value(dom_node& node,const std::string& name,const std::st
187187
decision.semantic_slice =
188188
"first animation; none, forwards, backwards, both";
189189
return true;
190+
} else if (name == "animation-play-state") {
191+
node.style.mutable_animations().animation_play_state_value = value;
192+
decision.classification = "partially-supported";
193+
decision.semantic_slice = "first animation; running, paused";
194+
return true;
190195
} else if (property_mask(name) == inline_border
191196
&& is_inline(inline_border)) {
192197
return true;

‎experiments/WebScene.NativeEngine.Probe/native/webscene_css_specified_coverage.h‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ inline constexpr auto specified_property_samples = std::to_array<specified_prope
4444
{"animation-delay","100ms"},{"animation-timing-function","ease-in-out"},{"animation-iteration-count","infinite"},
4545
{"animation-direction","alternate-reverse"},
4646
{"animation-fill-mode","both"},
47+
{"animation-play-state","paused"},
4748
{"box-shadow","1px 2px 3px 4px rgba(0,0,0,.5)"},
4849
{"background","#123456"},{"background-color","rgba(1,2,3,.5)"},
4950
{"background-image","linear-gradient(90deg, red, blue)"},{"background-repeat","no-repeat"},

‎experiments/WebScene.NativeEngine.Probe/native/webscene_css_transitions.h‎

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -256,6 +256,7 @@ inline void apply_animation_shorthand(node_style& style, const std::string& valu
256256
auto iterations = std::string("1");
257257
auto direction = std::string("normal");
258258
auto fill_mode = std::string("none");
259+
auto play_state = std::string("running");
259260
auto saw_time = false;
260261
const auto first = split_css_component_list(value, ',');
261262
for (const auto& token : split_value_tokens(
@@ -278,9 +279,10 @@ inline void apply_animation_shorthand(node_style& style, const std::string& valu
278279
} else if (lower == "normal" || lower == "reverse"
279280
|| lower == "alternate" || lower == "alternate-reverse") {
280281
direction = lower;
282+
} else if (lower == "running" || lower == "paused") {
283+
play_state = lower;
281284
} else if (lower != "none"
282-
&& lower != "backwards"
283-
&& lower != "running" && lower != "paused") {
285+
&& lower != "backwards") {
284286
name = token;
285287
}
286288
}
@@ -292,6 +294,7 @@ inline void apply_animation_shorthand(node_style& style, const std::string& valu
292294
animations.animation_iteration_count_value = iterations;
293295
animations.animation_direction_value = direction;
294296
animations.animation_fill_mode_value = fill_mode;
297+
animations.animation_play_state_value = play_state;
295298
}
296299

297300
inline void configure_keyframes(node_style& style,
@@ -305,6 +308,13 @@ inline void configure_keyframes(node_style& style,
305308
animations.rotation_keyframe_animation_signature.clear();
306309
animations.filter_keyframes.clear();
307310
animations.filter_keyframe_animation_signature.clear();
311+
const auto play_states = split_css_component_list(
312+
animations.animation_play_state_value, ',');
313+
const auto play_state = play_states.empty()
314+
? std::string("running") : ascii_lower(trim_value(play_states.front()));
315+
animations.keyframe_play_state = play_state == "paused"
316+
? node_style::animation_data::play_kind::paused
317+
: node_style::animation_data::play_kind::running;
308318
if (animations.animation_name_value == "none") return;
309319
const auto names = split_css_component_list(animations.animation_name_value, ',');
310320
if (names.empty()) return;

‎experiments/WebScene.NativeEngine.Probe/native/webscene_native_dom.h‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -211,6 +211,10 @@ struct node_style final {
211211
backwards,
212212
both
213213
};
214+
enum class play_kind : uint8_t {
215+
running,
216+
paused
217+
};
214218
std::string transition_property_value{"all"};
215219
std::string transition_duration_value{"0s"};
216220
std::string transition_delay_value{"0s"};
@@ -229,6 +233,7 @@ struct node_style final {
229233
std::string animation_iteration_count_value{"1"};
230234
std::string animation_direction_value{"normal"};
231235
std::string animation_fill_mode_value{"none"};
236+
std::string animation_play_state_value{"running"};
232237
std::string opacity_keyframe_animation_signature;
233238
std::vector<opacity_keyframe> opacity_keyframes;
234239
std::string rotation_keyframe_animation_signature;
@@ -240,6 +245,7 @@ struct node_style final {
240245
float opacity_keyframe_iterations{1};
241246
direction_kind keyframe_direction{direction_kind::normal};
242247
fill_kind keyframe_fill_mode{fill_kind::none};
248+
play_kind keyframe_play_state{play_kind::running};
243249
float opacity_keyframe_x1{0.25F};
244250
float opacity_keyframe_y1{0.1F};
245251
float opacity_keyframe_x2{0.25F};
@@ -1530,6 +1536,8 @@ struct dom_node final {
15301536
bool filter_keyframe_animation_filled{false};
15311537
node_style::animation_data::fill_kind keyframe_fill_mode{
15321538
node_style::animation_data::fill_kind::none};
1539+
bool keyframe_animation_paused{false};
1540+
double keyframe_animation_paused_at_ms{0};
15331541
bool keyframe_animation_end_event_sent{false};
15341542
uint32_t painted_foreground_rgba{0};
15351543
uint32_t color_animation_from_rgba{0};

‎experiments/WebScene.NativeEngine.Probe/native/webscene_native_dom_animations.inc‎

Lines changed: 56 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -259,6 +259,16 @@ void native_document::update_style_animations(dom_node& node)
259259
!= node.style.animations().filter_keyframe_animation_signature;
260260
const auto fill_mode_changed = animation.keyframe_fill_mode
261261
!= node.style.animations().keyframe_fill_mode;
262+
const auto has_configured_keyframes =
263+
!node.style.animations().opacity_keyframe_animation_signature.empty()
264+
|| !node.style.animations().rotation_keyframe_animation_signature.empty()
265+
|| !node.style.animations().filter_keyframe_animation_signature.empty();
266+
const auto desired_keyframe_pause = has_configured_keyframes
267+
&& node.style.animations().keyframe_play_state
268+
== node_style::animation_data::play_kind::paused;
269+
const auto was_keyframe_paused = animation.keyframe_animation_paused;
270+
const auto previous_pause_timestamp =
271+
animation.keyframe_animation_paused_at_ms;
262272
animation.keyframe_fill_mode = node.style.animations().keyframe_fill_mode;
263273
if (opacity_keyframe_changed || rotation_keyframe_changed
264274
|| filter_keyframe_changed) {
@@ -366,6 +376,32 @@ void native_document::update_style_animations(dom_node& node)
366376
animation.filter_keyframe_animation_valid;
367377
}
368378
}
379+
const auto keyframe_identity_changed = opacity_keyframe_changed
380+
|| rotation_keyframe_changed || filter_keyframe_changed;
381+
if (was_keyframe_paused
382+
&& (!desired_keyframe_pause || keyframe_identity_changed)) {
383+
const auto held_duration = std::max(
384+
0.0, animation_frame_timestamp_ms_ - previous_pause_timestamp);
385+
if (!opacity_keyframe_changed
386+
&& animation.opacity_keyframe_animation_active) {
387+
animation.opacity_keyframe_animation_started_ms += held_duration;
388+
}
389+
if (!rotation_keyframe_changed
390+
&& animation.rotation_keyframe_animation_active) {
391+
animation.rotation_keyframe_animation_started_ms += held_duration;
392+
}
393+
if (!filter_keyframe_changed
394+
&& animation.filter_keyframe_animation_active) {
395+
animation.filter_keyframe_animation_started_ms += held_duration;
396+
}
397+
}
398+
animation.keyframe_animation_paused = desired_keyframe_pause;
399+
if (desired_keyframe_pause
400+
&& (!was_keyframe_paused || keyframe_identity_changed)) {
401+
animation.keyframe_animation_paused_at_ms = animation_frame_timestamp_ms_;
402+
} else if (!has_configured_keyframes) {
403+
animation.keyframe_animation_paused_at_ms = 0;
404+
}
369405

370406
if (animation.opacity_animation_active && node.style.animations().transition_property_value == "none") {
371407
transition_events_.push_back({node.id,"transitioncancel","opacity",
@@ -1052,6 +1088,9 @@ bool native_document::advance_animations() noexcept
10521088
}
10531089

10541090
auto keyframe_animation_completed = false;
1091+
const auto keyframe_timestamp_ms = animation.keyframe_animation_paused
1092+
? animation.keyframe_animation_paused_at_ms
1093+
: animation_frame_timestamp_ms_;
10551094
if (animation.opacity_keyframe_animation_active) {
10561095
const auto sample_opacity = [&](float cycle_progress) {
10571096
const auto& stops = node.style.animations().opacity_keyframes;
@@ -1084,7 +1123,7 @@ bool native_document::advance_animations() noexcept
10841123
}
10851124
};
10861125
const auto elapsed_ms = static_cast<float>(
1087-
animation_frame_timestamp_ms_
1126+
keyframe_timestamp_ms
10881127
- animation.opacity_keyframe_animation_started_ms
10891128
- node.style.animations().opacity_keyframe_delay_ms);
10901129
if (elapsed_ms < 0) {
@@ -1110,8 +1149,10 @@ bool native_document::advance_animations() noexcept
11101149
} else {
11111150
animation.painted_opacity = node.style.opacity;
11121151
}
1113-
animation.opacity_keyframe_animation_active = false;
1114-
keyframe_animation_completed = true;
1152+
if (!animation.keyframe_animation_paused) {
1153+
animation.opacity_keyframe_animation_active = false;
1154+
keyframe_animation_completed = true;
1155+
}
11151156
advanced = advanced || keyframe_paint_visible;
11161157
} else {
11171158
sample_opacity(active_keyframe_progress(
@@ -1154,7 +1195,7 @@ bool native_document::advance_animations() noexcept
11541195
}
11551196
};
11561197
const auto elapsed_ms = static_cast<float>(
1157-
animation_frame_timestamp_ms_
1198+
keyframe_timestamp_ms
11581199
- animation.rotation_keyframe_animation_started_ms
11591200
- node.style.animations().opacity_keyframe_delay_ms);
11601201
if (elapsed_ms < 0) {
@@ -1184,8 +1225,10 @@ bool native_document::advance_animations() noexcept
11841225
node.style.transform_rotate_degrees;
11851226
animation.rotation_keyframe_animation_filled = false;
11861227
}
1187-
animation.rotation_keyframe_animation_active = false;
1188-
keyframe_animation_completed = true;
1228+
if (!animation.keyframe_animation_paused) {
1229+
animation.rotation_keyframe_animation_active = false;
1230+
keyframe_animation_completed = true;
1231+
}
11891232
advanced = advanced || keyframe_paint_visible;
11901233
} else {
11911234
sample_rotation(active_keyframe_progress(
@@ -1242,7 +1285,7 @@ bool native_document::advance_animations() noexcept
12421285
}
12431286
};
12441287
const auto elapsed_ms = static_cast<float>(
1245-
animation_frame_timestamp_ms_
1288+
keyframe_timestamp_ms
12461289
- animation.filter_keyframe_animation_started_ms
12471290
- node.style.animations().opacity_keyframe_delay_ms);
12481291
if (elapsed_ms < 0) {
@@ -1272,8 +1315,10 @@ bool native_document::advance_animations() noexcept
12721315
animation.filter_keyframe_underlying;
12731316
animation.filter_keyframe_animation_filled = false;
12741317
}
1275-
animation.filter_keyframe_animation_active = false;
1276-
keyframe_animation_completed = true;
1318+
if (!animation.keyframe_animation_paused) {
1319+
animation.filter_keyframe_animation_active = false;
1320+
keyframe_animation_completed = true;
1321+
}
12771322
advanced = advanced || keyframe_paint_visible;
12781323
} else {
12791324
sample_filter(active_keyframe_progress(
@@ -1344,7 +1389,8 @@ bool native_document::has_active_animations() const noexcept
13441389
|| animation->rotation_keyframe_animation_active
13451390
|| animation->filter_keyframe_animation_active;
13461391
return transition_active
1347-
|| (keyframe_active && intersects_visible_paint_area(*node));
1392+
|| (keyframe_active && !animation->keyframe_animation_paused
1393+
&& intersects_visible_paint_area(*node));
13481394
});
13491395
active_animation_demand_cache_valid_ = true;
13501396
return active_animation_demand_cache_;

‎experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_style.inc‎

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -290,6 +290,8 @@
290290
node.style.mutable_animations().animation_direction_value = "normal";
291291
} else if (name == "animationFillMode" || name == "animation-fill-mode") {
292292
node.style.mutable_animations().animation_fill_mode_value = "none";
293+
} else if (name == "animationPlayState" || name == "animation-play-state") {
294+
node.style.mutable_animations().animation_play_state_value = "running";
293295
} else if (name == "animation" || name == "animationName"
294296
|| name == "animation-name" || name == "animationDuration"
295297
|| name == "animation-duration" || name == "animationDelay"
@@ -304,6 +306,7 @@
304306
animations.animation_iteration_count_value = "1";
305307
animations.animation_direction_value = "normal";
306308
animations.animation_fill_mode_value = "none";
309+
animations.animation_play_state_value = "running";
307310
} else if (name == "opacity") {
308311
node.style.opacity = 1;
309312
node.style.inline_property_mask &= ~inline_opacity;
@@ -1437,6 +1440,8 @@
14371440
value = node->style.animations().transition_timing_function_value;
14381441
} else if (name == "animation-fill-mode") {
14391442
value = node->style.animations().animation_fill_mode_value;
1443+
} else if (name == "animation-play-state") {
1444+
value = node->style.animations().animation_play_state_value;
14401445
} else if (name == "opacity") {
14411446
value = serialize_css_number(node->painted_opacity_value());
14421447
} else if (name == "color") {
@@ -2046,7 +2051,8 @@
20462051
+ node->style.animations().animation_delay_value + " "
20472052
+ node->style.animations().animation_iteration_count_value + " "
20482053
+ node->style.animations().animation_direction_value + " "
2049-
+ node->style.animations().animation_fill_mode_value;
2054+
+ node->style.animations().animation_fill_mode_value + " "
2055+
+ node->style.animations().animation_play_state_value;
20502056
} else if (name == "animationName" || name == "animation-name") {
20512057
value = node->style.animations().animation_name_value;
20522058
} else if (name == "animationDuration" || name == "animation-duration") {
@@ -2063,6 +2069,8 @@
20632069
value = node->style.animations().animation_direction_value;
20642070
} else if (name == "animationFillMode" || name == "animation-fill-mode") {
20652071
value = node->style.animations().animation_fill_mode_value;
2072+
} else if (name == "animationPlayState" || name == "animation-play-state") {
2073+
value = node->style.animations().animation_play_state_value;
20662074
} else if (name == "zIndex") {
20672075
value = node->style.z_index_auto
20682076
? "auto" : std::to_string(node->style.z_index);
@@ -2576,6 +2584,8 @@
25762584
node->style.mutable_animations().animation_direction_value = value;
25772585
} else if (name == "animationFillMode") {
25782586
node->style.mutable_animations().animation_fill_mode_value = value;
2587+
} else if (name == "animationPlayState") {
2588+
node->style.mutable_animations().animation_play_state_value = value;
25792589
} else if (name == "opacity") {
25802590
node->style.opacity = raw_value->IsNullOrUndefined()
25812591
? 1.0F

0 commit comments

Comments
 (0)