Skip to content

feat(scene): keyframes that play from when an item's scene goes on - #43

Merged
kixelated merged 11 commits into
mainfrom
quest/m0/animation
Nov 5, 2026
Merged

kixelated merged 11 commits into
mainfrom
quest/m0/animation

Conversation

@kixelated

@kixelated kixelated commented Nov 4, 2026 •

Copy link
Copy Markdown
Contributor

Finishes quest/m0/animation.md and deletes it.

Problem

An item could only sit where it was placed. A switcher needs items that fly in, fade or blur in, and cameras that push in. The animation has to land on the same frames in the program, a recording, and a replay.

Approach

  • moq_scene::Item gets keyframes: Vec<Keyframe>. Each keyframe has:
    • at: the time after the item's scene goes on.
    • ease: how the item eases into it (linear, in, out, or in_out, the default; cubic, the same curve transitions use).
    • Offsets from where the item rests: x, y, width, and height in canvas pixels added to rect, a crop of what the item's crop leaves, and filters applied after the item's own.
  • Item::posed(since) returns the item as drawn: offsets mixed between the two keyframes around since, the first keyframe held before it starts, and the last held after it ends. Filters mix their settings (Filter::toward) when the two lists pair up by kind, in order. Otherwise they switch halfway, as a move transition does. An item without keyframes is borrowed, not cloned.
  • The compositor plays keyframes on the clock render is given. Each output has its own start time:
    • Program: when the scene went on program. A cut starts it at the next render, and a transition starts it at the transition's start.
    • Preview: when the scene went into preview (set_preview, even with the same scene).
    • A scene leaving in a transition keeps its own start time. Fade, slide, wipe, and move all draw posed items, so a move glides from the outgoing pose to the animating incoming one.
  • Prepared draws are shared between preview and program, now keyed by Item::settled(since) (the time clamped to the keyframes' span). The two outputs share a draw once both have settled.
  • Keyframes change through Draft::set_item, so they undo like any edit.
  • Studio:
    • A Keyframes panel (next to Filters) adds, times, eases, offsets, crops, filters, and removes the selected item's keyframes. Play restarts the preview's clock.
    • A preview drag grabs and snaps the item where its keyframes show it, and moves where it rests. Since the keyframes are offsets, the whole animation moves with it.
    • Interact sends browser input to a page where its keyframes show it. A drag on a page follows the item as it moves. If keyframes shrink the page to nothing, the release still lands, at the last point the page received.
  • Copied items are validated in a temporary scene, not the top layer, so an animated item copies and pastes into a scene. Pasting it into the top layer fails.
  • Transition::progress now calls Ease, and Crop::toward replaces the crop mix in the move transition.

Frame time

1920x1080 on an Apple M4 (Metal, release). Each figure is render plus a GPU wait over 1000 frames, measured with a temporary test that is not committed. The scene has a full-canvas fill, a 640x360 picture-in-picture with a drop shadow, and a 400x120 bar. In the keyframed version, the picture-in-picture moves and crops and the bar moves.

Program median (quiet host) median (loaded host, load ~15) CPU in render (loaded)
no keyframes 0.88 ms 2.31 ms 0.55 ms
animating, preview and program at different points 1.17 ms 2.99 ms 0.83 ms
settled (not run quiet) 2.33 ms 0.59 ms
  • While preview and program are at different points of an animation (for example, the program for a second after a cut, with the same scene still in preview), each output filters its keyframed items separately: one extra prepare per keyframed item. Once both have settled they share the prepare again, and the cost matches a scene without keyframes.
  • A keyframe filter adds a filter pass, as an item filter does.
  • No new textures or GPU copies.
  • Encoder load is unchanged, apart from the motion the animation puts on screen while it plays.

Impact

  • Scene file: items take an optional keyframes list. This is additive, so existing files load unchanged.
  • Panels: a new Keyframes panel. A preview drag now grabs an item where it shows, not where it rests.
  • Behavior: re-picking the scene already in preview plays its keyframes again. A cut or transition plays them from the start on program, even to the scene already on program.
  • Public API (moq-scene, unpublished): new Keyframe, Ease, Item::keyframes, Item::posed, and Compositor::preview_since (the moment of the keyframes the last rendered preview shows). Item literals need the new field (or ..Item::new(..)). No wire impact.
  • README: Keyframes section, panel row, scene-file example, and field reference.

Alternatives

  • Absolute keyframe values, like CSS: rejected by the maintainer. A drag would then fight the animation.
  • A uniform scale about the center, instead of width and height offsets: pixel offsets compose with the preview's edge and corner drags, so grabbing the shown rect and moving the rest stays exact. They also cover non-uniform growth.
  • Sparse keyframes, where each property animates only between the keyframes that set it, as in CSS: more flexible timing, but every keyframe row in the panel would need a set or unset toggle for each property. Dense keyframes, with every offset present and zero by default, keep the file and panel simpler. An independent timing can still be expressed with an extra keyframe.
  • A dedicated opacity offset: dropped. An opacity filter in the keyframe does the same, and Add keyframe copies the last keyframe's filters so they pair.
  • Keyframing the item's own filter settings: they would be absolute and break whenever the item's filter list is edited. Filters layered after the item's own stay offsets and never conflict with it.

Choices made without asking

  • Top-layer items cannot have keyframes. Validation refuses them, and the panel says why. The top layer never goes on program, so it has no start time, and the maintainer rejected triggers and hotkeys. Starting at studio launch would play an animation nobody sees and can't be previewed. Pasting an item with keyframes into the top layer fails loudly instead of dropping them silently.
  • A keyframe's filters cannot be a LUT or a mask. Validation refuses them. A filter that reads another source would have to take part in renaming and removing sources, clip copy and paste, and what gets painted, and no animation needs it yet.
  • Keyframes must be in time order, and validation refuses otherwise. The panel limits each time to the range between its neighbors. Two keyframes at the same time make a jump.
  • A width or height shrunk below zero draws nothing; the value is clamped, not refused, since it is a moment in the animation and not a resting place.
  • Program and preview start their clocks at the first render after a cut or a set_preview, so the first frame drawn is t = 0 on the program clock. A transition starts at the instant it is given, as before.
  • Add keyframe adds one at 0 s at rest. After that, it adds one 0.5 s after the last, at rest, with the last one's ease and filters, so their filters pair.

Follow-ups

None. Keyframes in the layout track belong to quest/m1/compose/layout.md, whose Plan now says the layout carries when each scene went on program.

Validation

  • just ci on 6cd8a28: passed (lints, quest tree, 207 tests).
  • cargo nextest run -p moq-scene --lib --run-ignored all compositor::tests:: on Metal (M4), on the 6cd8a28 tree: 28 passed. The new ones are keyframes_play_from_when_the_scene_goes_on, a_transition_starts_the_incoming_keyframes, and a_jump_draws_each_side_on_its_own_output.
  • Unit tests:
    • Keyframes: offsets, holding at both ends, jumps (keyframes at the same moment), easing curves, filters that switch halfway, validation, and the hand-written file format.
    • Clip: an animated item copies into a scene but not the top layer.
    • Interact: a keyframed page takes input where it shows, a drag follows it, and a page shrunk to nothing still gets the release.

Review

Codex gpt-6.1-sol, medium effort, via quest review --base origin/main --branch quest/m0/animation. Session rounds 2 to 7 (round 1 was #33's, which used the same branch name). Reviewed head: 6cd8a28, no findings.

  • Round 2 (bd27dde):
    • An animated item could not be copied, because clipboard validation used the top layer. Fixed in 6099946.
    • Browser input mapped through the item's resting place, not its keyframed one. Fixed in 6099946.
  • Round 3 (ac0aab2): a captured page drag used the pose from the press, not the current one. Fixed in 76b908b.
  • Round 4 (f3ce012): a page that keyframes shrank to nothing lost its release. Fixed in 68c00c7.
    • This was the design checkpoint. The maintainer chose to continue (comment).
  • Round 5 (68c00c7): no findings.
  • Online reviews on 68c00c7, answered here:
    • Equal-time keyframes (a jump) shared one prepared-draw key on either side of the jump, so the program could reuse the preview's pose on air (pullfrog thread; OpenAI review P2). Fixed in 39eb274 with Settled { Before, At, After }; the thread is resolved.
    • Preview hit-testing used the wall clock instead of the moment the preview frame shows (OpenAI review P2). Fixed in 6cd8a28.
    • CodeRabbit was rate-limited and posted no findings.
  • Round 6 (39eb274, after merging origin/main with feat(studio): guests see the program and hear a mix-minus #37 and feat(scene): microphones and system audio publish their own Opus tracks #40): no findings.
  • Round 7 (6cd8a28): no findings.

🤖 Generated with Claude Code

(Written by Claude Opus 5.5)

kixelated and others added 2 commits November 4, 2026 13:27
Quest-Claim: e94fafb24b1b4ee7
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@kixelated kixelated changed the title Keyframes feat(scene): keyframes that play from when an item's scene goes on Nov 4, 2026
@kixelated

Copy link
Copy Markdown
Contributor Author

Review checkpoint (session round 4): the maintainer picked continue (iterate, then merge). Round 5 reviews 68c00c7 with no design change.

(Written by Claude Opus 5.5)

@kixelated
kixelated marked this pull request as ready for review November 5, 2026 05:44
@coderabbitai

coderabbitai Bot commented Nov 5, 2026 •

Copy link
Copy Markdown

Warning

Review limit reached

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

Next included review available in 19 seconds.

Check out review usage here.

View limit details

Limit details: You’ve used the included review currently available.

Learn how review limits work.

Review configuration:

Run configuration

  • Source mode: Auto
  • Repository: Unavailable
  • Branch: Unavailable
  • File: No configuration file from this repository applied
  • Commit: Unavailable
  • Layers: Organization UI
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: 3f41d484-d9ff-42f5-8686-1c153d05db3b
Commits

Reviewing files that changed from the base of the PR and between 0f454c8 and 45fd6ef.

Files selected for processing: 11

  • README.md
  • quest/m0/README.md
  • quest/m0/animation.md
  • quest/m0/stinger.md
  • quest/m1/compose/layout.md
  • rs/moq-scene/src/clip.rs
  • rs/moq-scene/src/collection.rs
  • rs/moq-scene/src/compositor.rs
  • rs/moq-scene/src/lib.rs
  • rs/moq-studio/src/interact.rs
  • rs/moq-studio/src/ui.rs
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

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

@kixelated kixelated left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Automated review by review (OpenAI)

Reviewed commit: 68c00c7

  • [P2] Keep both sides of an equal-time jump distinct in the draw cache (rs/moq-scene/src/collection.rs:612–615). With two keyframes at 1s, offset() uses the first before 1s and the last afterward, but settled() returns the same cache time on both sides. A settled preview can populate the opaque pose, then a newly cut program reuses it during its first second when the first pose should apply. Preserve the before-first versus at/after-first distinction and add a preview-to-program regression.
  • [P2] Hit-test at the displayed preview frame's time (rs/moq-studio/src/ui.rs:755–756, also 793). Input and arranging use the current time while the texture shows the last program tick. At 1 fps a fast-moving item can visibly remain at its old position while a click there tests against its later position and misses. Use the elapsed time of the last rendered preview for hit testing and handles.

Scene-owned offsets and separate preview/program clocks fit the feature. Verification: GitHub-only static review; I did not run tests or GPU playback. Hosted Check was still running when inspected.

@pullfrog pullfrog 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.

Important

An item with equal-time keyframes (a deliberate jump) can show its after-jump pose on program for the first part of the scene, because the prepared-draw cache conflates the before-first and after-last holds.

Reviewed changes

  • Keyframes in the scene: Item gains ordered keyframes, Keyframe::toward mixes offsets/crop/filters, Item::posed holds the first and last, and validation rejects top-layer keyframes, bad crops, non-finite offsets, and source-reading filters.
  • Clocks in the compositor: preview and program get separate starts; a cut restarts the program clock, a transition starts the incoming keyframes at the click, and a per-render prepared cache reuses items at settled times.
  • Studio editing: a Keyframes tab adds, edits, removes, and replays keyframes; arranging and browser page input pose items at the preview time; clips carry keyframes and paste validation covers them.
  • Docs and quests: the README documents keyframes with an example, the animation quest is removed, and the stinger and layout quests describe the landed behavior.

Pullfrog  | Fix all ➔ | Fix 👍s ➔ | View workflow run | Using big-pickle (free) | 𝕏

Comment thread rs/moq-scene/src/collection.rs Resolved
kixelated and others added 3 commits November 4, 2026 22:05
@kixelated

Copy link
Copy Markdown
Contributor Author

On the OpenAI review of 68c00c7:

  • [P2] Equal-time jump in the draw cache: fixed in 39eb274, the same finding as pullfrog's thread. Item::settled returns Settled { Before, At, After }, and there are unit and GPU regression tests.
  • [P2] Hit-test at the displayed preview frame's time: fixed in 6cd8a28. Compositor::preview_since() now returns the moment of the last render, which is what the preview texture shows, not the wall clock. Arranging and Interact both use it, and keyframes_play_from_when_the_scene_goes_on asserts it.

Codex reviewed 6cd8a28 locally with no findings. just ci passes.

(Written by Claude Opus 5.5)

@kixelated kixelated left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Automated review by review (OpenAI)

Reviewed commit: 6cd8a28

Both earlier findings are fixed: the cache distinguishes before/at/after poses for equal-time jumps, and interaction and arranging use the displayed preview's recorded render time. New tests cover the jump and stored timestamp. No new actionable findings in the update; the targeted changes fit the existing rendering model.

Verification: GitHub-only static review. I did not run tests or GPU playback; the timestamp assertion is not a full interactive low-frame-rate test.

@pullfrog pullfrog 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.

✅ No new issues found.

Reviewed changes

  • Replaced the clamped Duration prepared-draw key with Settled { Before, At(Duration), After }, so both sides of an equal-time jump get distinct cache entries.
  • Recorded the preview elapsed time at each render and made Compositor::preview_since() return it, so arranging and browser input hit-test the frame the texture actually shows.
  • Added the keyframes_at_one_moment_jump unit test, the a_jump_draws_each_side_on_its_own_output GPU test, and a preview_since() assertion in keyframes_play_from_when_the_scene_goes_on.

Both prior findings are addressed: the cache-key collision is fixed by Settled, and the render/hit-test skew is fixed by preview_shown. The new classification cannot conflate two poses (only over-split shares), and no other callers of the old preview_since(now) signature remain.

Pullfrog  | View workflow run | Using big-pickle (free) | 𝕏

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