Skip to content

feat(animation): playback speed, loop region, auto-key (Phase 5 slice A) - #356

Merged
fernandotonon merged 4 commits into
masterfrom
feat/phase5-slice-a-anim-timeline
May 2, 2026
Merged

feat(animation): playback speed, loop region, auto-key (Phase 5 slice A)#356
fernandotonon merged 4 commits into
masterfrom
feat/phase5-slice-a-anim-timeline

Conversation

@fernandotonon

@fernandotonon fernandotonon commented May 1, 2026

Copy link
Copy Markdown
Owner

Summary

First slice of #260 — animation timeline polish.

  • Playback speed (0.25× / 0.5× / 1× / 2× / 4×): global multiplier exposed as a ComboBox next to the Play button in the Inspector's Animations section. In-app only — scales dt before setTimePosition; keyframes and clip length untouched, so saved/exported meshes play at native speed elsewhere.
  • Loop region (loopStart / loopEnd / loopRegionActive): scoped to the entity + animation selected in the Animation Control panel. Other entities advance normally. Wraps via fmod so large overshoots fold back into the region. Resets to [0, length] whenever a new clip is selected. Two transparent drag handles let you reposition the in/out points while playing; the timeline canvas shades the active region.

Auto-keyframe was removed from this slice — it's meaningless without a way to directly manipulate bones in the viewport, and the original wiring would have written the wrong pose anyway. Split into #358 (bone manipulation gizmo + auto-keyframe) so it can land on top of real bone editing.

Test plan

  • Load an animated mesh → press Play → hit 2× / 0.5× → animation visibly speeds up / slows down
  • Toggle Loop ON → drag the blue handles in the timeline → playback wraps inside the region for the selected entity only
  • Load two animated meshes → speed scales both; loop only affects the selected one
  • Save the mesh, reopen → plays at native (1×) timing (speed is not baked)
  • Linux CI: AnimationControlControllerPlaybackTest.* (10 cases) + SelectAnimationResetsLoopRegion

🤖 Generated with Claude Code

@coderabbitai

coderabbitai Bot commented May 1, 2026

Copy link
Copy Markdown

Warning

Rate limit exceeded

@fernandotonon has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 46 minutes and 21 seconds before requesting another review.

To keep reviews running without waiting, you can enable usage-based add-on for your organization. This allows additional reviews beyond the hourly cap. Account admins can enable it under billing.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 6f6c0916-4fbe-4983-b644-7005409f25e0

📥 Commits

Reviewing files that changed from the base of the PR and between ed14531 and 6bcc2e1.

📒 Files selected for processing (6)
  • qml/AnimationControlPanel.qml
  • qml/PropertiesPanel.qml
  • src/AnimationControlController.cpp
  • src/AnimationControlController.h
  • src/AnimationControlController_test.cpp
  • src/mainwindow.cpp
📝 Walkthrough

Walkthrough

Animation playback now supports variable speed control and configurable loop regions. The QML interface adds a speed selector and loop toggle with interactive handle overlays. The controller manages playback speed, loop start/end boundaries, and a new time advancement helper that applies speed scaling and wraps time within active loop regions. Frame updates integrate this logic to advance animations accordingly.

Changes

Cohort / File(s) Summary
QML UI Controls
qml/AnimationControlPanel.qml, qml/PropertiesPanel.qml
Added loop ON/OFF toggle with interactive shaded loop interval rendering and draggable boundary handles; added playback speed ComboBox selector synced to controller state.
Controller Interface
src/AnimationControlController.h
Extended public API with Q_PROPERTYs and methods for playback speed and loop-region configuration (start, end, active state); added advanceTime() helper and new NOTIFY signals.
Controller Implementation
src/AnimationControlController.cpp
Implemented playback speed and loop-region setters with clamping and signal emission; added advanceTime() logic applying speed scaling and conditional loop wrapping (handles forward/reverse overshoot); resets loop on clip selection.
Frame Update Integration
src/mainwindow.cpp
Modified animation playback loop to compute scaled dt from global playback speed; conditionally advances selected animation using advanceTime() with loop wrapping, others via simple addTime().
Test Coverage
src/AnimationControlController_test.cpp
Added tests validating playback speed defaults, clamping, signal emission, advanceTime() scaling/wrapping behavior, and loop-region state management including edge cases (overshoot, degenerate bounds).

Sequence Diagram

sequenceDiagram
    participant User as User
    participant QML as QML UI
    participant Ctrl as AnimationControlController
    participant MainWin as MainWindow
    participant AnimState as AnimationState

    User->>QML: Adjust playback speed/loop toggle
    QML->>Ctrl: setPlaybackSpeed() / setLoopRegionActive()
    Ctrl->>Ctrl: Clamp values, emit signal
    QML->>QML: Update UI display

    MainWin->>MainWin: frameRenderingQueued()
    MainWin->>Ctrl: Get current playback speed
    MainWin->>Ctrl: advanceTime(currentTime, dt)
    Ctrl->>Ctrl: Apply speed scaling
    Ctrl->>Ctrl: Conditionally wrap into loop region
    Ctrl->>MainWin: Return advanced time
    MainWin->>AnimState: setTimePosition(advancedTime)
    AnimState->>AnimState: Update animation frame
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Possibly related PRs

  • PR #231: Introduces the foundational AnimationControlController and QML animation UI that these changes extend with playback speed and loop-region management.
  • PR #210: Modifies the same animation control UI components and controller API, adding related playback features to the animation system.

Poem

🐰 A swift bunny hops through time,
With speed controls and loops sublime,
Loop handles dance across the frame,
Each animation speeds or slows its claim,
Wrapping smoothly 'round the bend! 🎞️✨

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 8.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed Title accurately reflects the main changeset: implements playback speed, loop region, and mentions auto-key (Phase 5 slice A) as promised in objectives.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description check ✅ Passed PR description includes clear Summary section outlining both main features (playback speed and loop region) with specific UI/functional details, but lacks Technical Details section as specified in the template.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/phase5-slice-a-anim-timeline

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share
Review rate limit: 0/1 reviews remaining, refill in 46 minutes and 21 seconds.

Comment @coderabbitai help to get the list of available commands and usage tips.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: bb6eeef50c

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread qml/AnimationControlPanel.qml Outdated
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.SizeHorCursor
drag.target: parent

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve handle bindings while dragging loop markers

Using drag.target: parent on the loop marker rectangles mutates their x property directly, which breaks the declarative x bindings to loopStart/loopEnd after the first drag. Once that happens, later controller-driven updates (for example selecting a new clip, changing animation length, or external loop value changes) can leave the handles visually out of sync with the actual loop region values. Consider driving loop values from pointer deltas (without dragging the visual item itself) so the handle positions stay purely bound.

Useful? React with 👍 / 👎.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (1)
src/AnimationControlController.cpp (1)

307-323: 💤 Low value

Minor: Signal may emit when value unchanged after clamping.

The early-return check qFuzzyCompare(s, m_loopStart) happens before the constraint enforcement on line 312. If the caller sets loopStart to a value larger than loopEnd, and m_loopStart was already clamped to m_loopEnd, the function will emit loopRegionChanged() even though the final value is unchanged.

Same pattern applies to setLoopEnd.

This is functionally harmless (just an extra signal) but could be tightened by comparing before emission:

♻️ Optional: avoid redundant signal emission
 void AnimationControlController::setLoopStart(double s)
 {
     if (s < 0.0) s = 0.0;
-    if (qFuzzyCompare(s, m_loopStart)) return;
-    m_loopStart = s;
+    double newVal = s;
     if (m_loopEnd > 0.0 && m_loopStart > m_loopEnd) m_loopStart = m_loopEnd;
+    if (newVal > m_loopEnd && m_loopEnd > 0.0) newVal = m_loopEnd;
+    if (qFuzzyCompare(newVal, m_loopStart)) return;
+    m_loopStart = newVal;
     emit loopRegionChanged();
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/AnimationControlController.cpp` around lines 307 - 323, The early
qFuzzyCompare check in setLoopStart and setLoopEnd happens before clamping
against the opposite boundary, so callers that pass out-of-range values can
trigger emit loopRegionChanged() even when the stored m_loopStart/m_loopEnd
remains unchanged; fix by applying the clamping logic (the lines that enforce s
>= 0 and clamp s to m_loopEnd/m_loopStart) first, then perform qFuzzyCompare(s,
m_loopStart) / qFuzzyCompare(s, m_loopEnd) and only update the member and emit
loopRegionChanged() if the post-clamp value differs; update both setLoopStart
and setLoopEnd accordingly.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@qml/AnimationControlPanel.qml`:
- Around line 490-503: The loop shading and handle positions currently use a
hard-coded pad/track offset (pad = 13) and avail value, causing misalignment;
update the painting and drag math to use the timeSlider's actual geometry (e.g.,
timeSlider.leftPadding for pad and timeSlider.availableWidth for avail) when
computing lx/rx and any handle positions so the loop range matches the slider
groove; apply the same change wherever pad/avail is used for
loopRegionActive/loopStart/loopEnd (including the other blocks mentioned around
lines 540–549 and 551–597) and keep references to
AnimationControlController.loopRegionActive, loopStart, loopEnd, and the
timeSlider properties.

In `@qml/PropertiesPanel.qml`:
- Around line 1623-1629: The currentIndex binding in PropertiesPanel.qml can
incorrectly return a hardcoded fallback index (2) causing the ComboBox to
display "1x" for unsupported playback speeds; update the currentIndex logic that
references AnimationControlController.playbackSpeed and values to compute the
nearest preset instead of returning 2—iterate values, track the minimum absolute
difference and its index, and return that index; alternatively you can snap the
incoming speed in AnimationControlController.setPlaybackSpeed(), but the quick
QML-side fix is to replace the fallback with the nearest-index selection so the
UI always reflects the closest preset.

---

Nitpick comments:
In `@src/AnimationControlController.cpp`:
- Around line 307-323: The early qFuzzyCompare check in setLoopStart and
setLoopEnd happens before clamping against the opposite boundary, so callers
that pass out-of-range values can trigger emit loopRegionChanged() even when the
stored m_loopStart/m_loopEnd remains unchanged; fix by applying the clamping
logic (the lines that enforce s >= 0 and clamp s to m_loopEnd/m_loopStart)
first, then perform qFuzzyCompare(s, m_loopStart) / qFuzzyCompare(s, m_loopEnd)
and only update the member and emit loopRegionChanged() if the post-clamp value
differs; update both setLoopStart and setLoopEnd accordingly.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: e30d7da7-7b5e-4e8f-b185-47de663fa8a8

📥 Commits

Reviewing files that changed from the base of the PR and between 7ef27ad and ed14531.

📒 Files selected for processing (6)
  • qml/AnimationControlPanel.qml
  • qml/PropertiesPanel.qml
  • src/AnimationControlController.cpp
  • src/AnimationControlController.h
  • src/AnimationControlController_test.cpp
  • src/mainwindow.cpp

Comment thread qml/AnimationControlPanel.qml
Comment thread qml/PropertiesPanel.qml
fernandotonon and others added 4 commits May 1, 2026 22:43
Adds the timeline polish from #260:

- playbackSpeed (0.25–4×) global multiplier; ComboBox sits next to the
  Play button in the Inspector's Animations section. In-app only —
  scales dt before setTimePosition; keyframes/length untouched.
- loopStart/loopEnd/loopRegionActive scoped to the entity+animation
  selected in the Animation Control panel. Other entities advance at
  native timing. Wraps via fmod so large overshoots fold back inside
  the region. Resets to [0, length] on each new selection.
- autoKey toggle: end-of-drag in TransformOperator pushes a keyframe
  on the active bone via AnimationControlController::addKeyframe().
- QML: timeline canvas shades the loop region and renders blue in/out
  markers; two transparent drag handles let you reposition them.

Tests: 14 new pure-data tests in a separate fixture (no Ogre needed)
covering speed scaling, signal emission, loop wrap (basic, large
overshoot, degenerate, inactive passthrough, clamping), and auto-key
safety. 2 Ogre-fixture tests for the auto-key add-keyframe path and
loop-region reset on selection (run on Linux CI).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Codex flagged that drag.target: parent on the loop-marker rectangles
would mutate their x property directly, breaking the declarative bind
to loopStart/loopEnd. After the first drag, controller-driven updates
(new clip selected, length changed, external value change) could leave
the handles visually out of sync.

Drop drag.target and compute the new time from mouseX inside the
MouseArea instead. The handle's x binding stays intact; the controller
remains the single source of truth.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Auto-key only makes sense once bones are directly manipulable in the
viewport (click a bone → drag the gizmo). The original wiring fired
on scene-node end-of-drag and sampled an interpolated pose, which is
not what the toggle's name implies. Without a bone-gizmo there is no
honest behavior to ship.

Splitting it out into #358 (bone manipulation gizmo + auto-keyframe).
Slice A keeps speed scaling + per-entity loop region, which work on
their own.

Removes:
- autoKey property/setter/signal + autoKeyOnTransform() from controller
- "Auto Key" toggle from AnimationControlPanel.qml
- TransformOperator end-of-drag hook (and its include)
- 5 auto-key tests (4 pure-data + 1 Ogre-fixture)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- mainwindow.cpp (Sonar S5350): animCtrl is now const auto*.
- mainwindow.cpp (Sonar S5827 ×2): use auto for static_cast results
  where the type is already on the RHS.
- AnimationControlController.cpp (Sonar S2681 ×2 / CodeRabbit nitpick):
  brace single-line ifs in setLoopStart / setLoopEnd, and clamp
  before the qFuzzyCompare bail-out so we don't emit
  loopRegionChanged when the request collapses to the existing value.
- AnimationControlPanel.qml (CodeRabbit major): drop the hard-coded
  pad=13 / avail=width-26 in the timeline canvas and loop-handles
  layer; bind to timeSlider.leftPadding and timeSlider.availableWidth
  so the loop shading and drag handles track the slider groove
  across Qt styles, DPI settings, and platforms.
- PropertiesPanel.qml (CodeRabbit minor): speed combobox now picks
  the nearest preset rather than silently showing 1× when the
  controller's value isn't an exact match.
- AnimationControlController_test.cpp: align with master PR #355's
  new pattern — ASSERT_TRUE(canLoadMeshFiles()) instead of
  GTEST_SKIP() (CI now requires headless GL to work).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@fernandotonon
fernandotonon force-pushed the feat/phase5-slice-a-anim-timeline branch from ed14531 to 6bcc2e1 Compare May 2, 2026 02:52
@sonarqubecloud

sonarqubecloud Bot commented May 2, 2026

Copy link
Copy Markdown

@fernandotonon
fernandotonon merged commit 37665c4 into master May 2, 2026
19 checks passed
@fernandotonon
fernandotonon deleted the feat/phase5-slice-a-anim-timeline branch May 2, 2026 03:35
fernandotonon added a commit that referenced this pull request May 3, 2026
…#384)

* fix(animation): addKeyframe captures live bone pose + auto-key toggle

Closes #383. Two related fixes that build toward #358's full bone gizmo
without requiring it first:

1. addKeyframe captures the live bone pose
- Was using getInterpolatedKeyFrame to sample the existing animation
  curve at the scrub time. With a flat or empty curve, the new
  keyframe came out identity TRS — the user-visible "blank registry"
  bug. Now reads the bone's current local TRS via
  bone->getPosition() - bone->getInitialPosition() etc., the same
  math the blender's bake (slice B) and the dope sheet's keyframe
  TRS preservation already use.
- Means: drag the entity in the viewport, scrub to a fresh time, hit
  +KF, and the keyframe captures the actual pose under the cursor.

2. Auto-key toggle wired to TransformOperator end-of-drag
- Re-add the autoKey property + signal + setter (slice A originally
  shipped this; we ripped it out in #356's cleanup commit because
  bone manipulation didn't exist yet).
- New "Auto Key" button next to the loop toggle in the Animation
  Control panel.
- TransformOperator::mouseReleaseEvent calls
  AnimationControlController::autoKeyOnTransform() after every
  scene-node transform commit. With autoKey enabled and a bone-track
  selected, that pushes a keyframe at the current scrub time
  capturing the bone's live pose.

This isn't a bone-direct gizmo — that's still in #358. But combined
with the addKeyframe pose-capture fix, users can now record drag-and-
snapshot animations using the existing scene-node gizmo without
manually hitting +KF after every move.

Tests
- AddKeyframeCapturesBonePose: manually offset a bone, scrub, +KF;
  the new keyframe's translate.x matches the manual offset.
- AutoKeyOnTransformPushesKeyframeWhenEnabled: autoKey off → no-op;
  autoKey on → autoKeyOnTransform pushes a new keyframe.
- AutoKeyDefaultsOff + AutoKeyToggleEmitsSignal pure-data.
- AutoKeyOnTransformNoOpWithoutSelection: never crashes when called
  before any selection is made.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(animation): place auto-key playback tests after fixture class

The 3 AutoKey TEST_F blocks using AnimationControlControllerPlaybackTest
were inserted before the fixture's class declaration, which caused the
unit-tests-linux job to fail with "marked 'override', but does not
override" errors. Move them after the existing PasteRejectsMalformedJson
test where the fixture is in scope.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(animation): address PR #384 review feedback

- Gate auto-key on actual transform commits (Codex P1): plain clicks
  and zero-delta releases no longer push duplicate keys. Hoist
  TransformOperator's `changed` flag into `nodeTransformCommitted` and
  call autoKeyOnTransform only when true.
- Reuse same-time keyframes (CodeRabbit Major): addKeyframe now finds
  any existing key within 1 ms of the scrub time and updates it in
  place instead of stacking duplicates the rest of the controller
  treats as invalid.
- Add Sentry breadcrumbs (CodeRabbit Major): "ui.action" on Auto Key
  toggle and on every auto-key write so the new flow is traceable.
- New test covers the dedup path (autoKeyOnTransform fired twice at
  the same scrub time → keyframe count grows by exactly 1).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant