summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rwxr-xr-x.direnv/bin/nix-direnv-reload19
l---------.direnv/flake-inputs/159fr3bn9vwspn0b9084k563qzyq2cm7-source1
l---------.direnv/flake-inputs/llgwlxshmy0ifvxh7f8wq53vk5x7vd13-source1
l---------.direnv/flake-profile-a5d5b61aa8a61b7d9d765e1daf971a9a578f1cfa1
-rw-r--r--.direnv/flake-profile-a5d5b61aa8a61b7d9d765e1daf971a9a578f1cfa.rc2340
-rw-r--r--Cargo.lock321
-rw-r--r--Cargo.toml12
-rw-r--r--flake.nix41
-rw-r--r--notes/gpui-port.md98
-rw-r--r--notes/roadmap.md30
-rw-r--r--rust/crates/animation/Cargo.toml10
-rw-r--r--rust/crates/animation/src/bezier.rs156
-rw-r--r--rust/crates/animation/src/channel_data.rs92
-rw-r--r--rust/crates/animation/src/channel_layout.rs124
-rw-r--r--rust/crates/animation/src/color.rs137
-rw-r--r--rust/crates/animation/src/id.rs61
-rw-r--r--rust/crates/animation/src/interpolation.rs467
-rw-r--r--rust/crates/animation/src/keyframes.rs1150
-rw-r--r--rust/crates/animation/src/lib.rs1
-rw-r--r--rust/crates/animation/src/types.rs45
-rw-r--r--rust/crates/commands/Cargo.toml15
-rw-r--r--rust/crates/commands/src/batch_command.rs61
-rw-r--r--rust/crates/commands/src/bookmarks.rs94
-rw-r--r--rust/crates/commands/src/command.rs46
-rw-r--r--rust/crates/commands/src/history.rs68
-rw-r--r--rust/crates/commands/src/lib.rs18
-rw-r--r--rust/crates/commands/src/preview_tracker.rs28
-rw-r--r--rust/crates/commands/src/project/mod.rs3
-rw-r--r--rust/crates/commands/src/project/update_project_settings.rs77
-rw-r--r--rust/crates/commands/src/project_ext.rs20
-rw-r--r--rust/crates/commands/src/scene/create_scene.rs46
-rw-r--r--rust/crates/commands/src/scene/delete_scene.rs61
-rw-r--r--rust/crates/commands/src/scene/mod.rs15
-rw-r--r--rust/crates/commands/src/scene/move_bookmark.rs50
-rw-r--r--rust/crates/commands/src/scene/remove_bookmark.rs54
-rw-r--r--rust/crates/commands/src/scene/rename_scene.rs58
-rw-r--r--rust/crates/commands/src/scene/toggle_bookmark.rs48
-rw-r--r--rust/crates/commands/src/scene/update_bookmark.rs49
-rw-r--r--rust/crates/commands/src/timeline/element/delete_elements.rs64
-rw-r--r--rust/crates/commands/src/timeline/element/mod.rs3
-rw-r--r--rust/crates/commands/src/timeline/element/move_elements.rs145
-rw-r--r--rust/crates/commands/src/timeline/element/shift_split_remainder.rs58
-rw-r--r--rust/crates/commands/src/timeline/element/split_elements.rs225
-rw-r--r--rust/crates/commands/src/timeline/element/update_elements.rs72
-rw-r--r--rust/crates/commands/src/timeline/mod.rs5
-rw-r--r--rust/crates/commands/src/timeline/track/add_track.rs69
-rw-r--r--rust/crates/commands/src/timeline/track/mod.rs9
-rw-r--r--rust/crates/commands/src/timeline/track/remove_track.rs43
-rw-r--r--rust/crates/commands/src/timeline/track/toggle_track_mute.rs49
-rw-r--r--rust/crates/commands/src/timeline/track/toggle_track_visibility.rs54
-rw-r--r--rust/crates/commands/src/timeline/tracks_snapshot.rs33
-rw-r--r--rust/crates/commands/src/tracks.rs42
-rwxr-xr-xrust/crates/compositor/tests/offscreen_render.rs96
-rw-r--r--rust/crates/effect-defs/Cargo.toml9
-rw-r--r--rust/crates/effect-defs/src/lib.rs64
-rw-r--r--rust/crates/effect-defs/src/math.rs57
-rw-r--r--rust/crates/effect-defs/src/params.rs165
-rw-r--r--rust/crates/effect-defs/src/registry.rs35
-rw-r--r--rust/crates/effect-defs/src/types.rs30
-rw-r--r--rust/crates/gpu/src/context.rs62
-rw-r--r--rust/crates/gpu/src/lib.rs2
-rw-r--r--rust/crates/graphics/Cargo.toml7
-rw-r--r--rust/crates/graphics/src/lib.rs1
-rw-r--r--rust/crates/mask-defs/Cargo.toml8
-rw-r--r--rust/crates/mask-defs/src/lib.rs1
-rw-r--r--rust/crates/media/Cargo.toml9
-rw-r--r--rust/crates/media/src/lib.rs610
-rw-r--r--rust/crates/media/tests/decode.rs193
-rw-r--r--rust/crates/media/tests/fixtures/sample.mp4bin0 -> 81284 bytes
-rw-r--r--rust/crates/media/tests/fixtures/tone.wavbin0 -> 88278 bytes
-rw-r--r--rust/crates/playback/Cargo.toml7
-rw-r--r--rust/crates/playback/src/lib.rs316
-rw-r--r--rust/crates/scene/Cargo.toml10
-rw-r--r--rust/crates/scene/src/animation.rs127
-rw-r--r--rust/crates/scene/src/effect.rs13
-rw-r--r--rust/crates/scene/src/element.rs187
-rw-r--r--rust/crates/scene/src/iso_date.rs19
-rw-r--r--rust/crates/scene/src/lib.rs18
-rw-r--r--rust/crates/scene/src/mask.rs128
-rw-r--r--rust/crates/scene/src/params.rs12
-rw-r--r--rust/crates/scene/src/project.rs76
-rw-r--r--rust/crates/scene/src/scene.rs33
-rw-r--r--rust/crates/scene/src/track.rs88
-rw-r--r--rust/crates/scene/tests/round_trip.rs190
-rw-r--r--rust/crates/storage/Cargo.toml16
-rw-r--r--rust/crates/storage/src/lib.rs256
-rw-r--r--rust/crates/storage/tests/store.rs276
-rw-r--r--rust/crates/text/Cargo.toml9
-rw-r--r--rust/crates/text/src/lib.rs1
-rw-r--r--rust/crates/timeline/Cargo.toml12
-rw-r--r--rust/crates/timeline/src/audio_separation.rs97
-rw-r--r--rust/crates/timeline/src/creation.rs11
-rw-r--r--rust/crates/timeline/src/defaults.rs130
-rw-r--r--rust/crates/timeline/src/drag_utils.rs14
-rw-r--r--rust/crates/timeline/src/lib.rs96
-rw-r--r--rust/crates/timeline/src/placement/apply.rs101
-rw-r--r--rust/crates/timeline/src/placement/compatibility.rs43
-rw-r--r--rust/crates/timeline/src/placement/insert_index.rs81
-rw-r--r--rust/crates/timeline/src/placement/main_track.rs44
-rw-r--r--rust/crates/timeline/src/placement/mod.rs104
-rw-r--r--rust/crates/timeline/src/placement/overlap.rs34
-rw-r--r--rust/crates/timeline/src/placement/resolve.rs223
-rw-r--r--rust/crates/timeline/src/placement/track_factory.rs62
-rw-r--r--rust/crates/timeline/src/resize.rs401
-rw-r--r--rust/crates/timeline/src/retime.rs71
-rw-r--r--rust/crates/timeline/src/ripple.rs265
-rw-r--r--rust/crates/timeline/src/scenes.rs120
-rw-r--r--rust/crates/timeline/src/snap.rs113
-rw-r--r--rust/crates/timeline/src/split.rs143
-rw-r--r--rust/crates/timeline/src/track_capabilities.rs9
-rw-r--r--rust/crates/timeline/src/track_element_update.rs97
-rw-r--r--rust/crates/timeline/src/types.rs69
-rw-r--r--rust/crates/timeline/src/update_pipeline.rs158
-rw-r--r--rust/crates/transfer/Cargo.toml10
-rw-r--r--rust/crates/transfer/src/container.rs277
-rw-r--r--rust/crates/transfer/src/lib.rs8
-rw-r--r--rust/crates/transfer/tests/container.rs95
-rw-r--r--rust/crates/transfer/tests/cross_compat.rs56
-rw-r--r--rust/crates/transfer/tests/fixtures/web-built.ocpbin0 -> 660 bytes
-rw-r--r--script/cross-check.ts49
120 files changed, 12897 insertions, 16 deletions
diff --git a/.direnv/bin/nix-direnv-reload b/.direnv/bin/nix-direnv-reload
new file mode 100755
index 00000000..22154755
--- /dev/null
+++ b/.direnv/bin/nix-direnv-reload
@@ -0,0 +1,19 @@
+#!/usr/bin/env bash
+set -e
+if [[ ! -d "/home/cashmere/opencut-classic" ]]; then
+ echo "Cannot find source directory; Did you move it?"
+ echo "(Looking for "/home/cashmere/opencut-classic")"
+ echo 'Cannot force reload with this script - use "direnv reload" manually and then try again'
+ exit 1
+fi
+
+# rebuild the cache forcefully
+_nix_direnv_force_reload=1 direnv exec "/home/cashmere/opencut-classic" true
+
+# Update the mtime for .envrc.
+# This will cause direnv to reload again - but without re-building.
+touch "/home/cashmere/opencut-classic/.envrc"
+
+# Also update the timestamp of whatever profile_rc we have.
+# This makes sure that we know we are up to date.
+touch -r "/home/cashmere/opencut-classic/.envrc" "/home/cashmere/opencut-classic/.direnv"/*.rc
diff --git a/.direnv/flake-inputs/159fr3bn9vwspn0b9084k563qzyq2cm7-source b/.direnv/flake-inputs/159fr3bn9vwspn0b9084k563qzyq2cm7-source
new file mode 120000
index 00000000..8a4488f2
--- /dev/null
+++ b/.direnv/flake-inputs/159fr3bn9vwspn0b9084k563qzyq2cm7-source
@@ -0,0 +1 @@
+/nix/store/159fr3bn9vwspn0b9084k563qzyq2cm7-source \ No newline at end of file
diff --git a/.direnv/flake-inputs/llgwlxshmy0ifvxh7f8wq53vk5x7vd13-source b/.direnv/flake-inputs/llgwlxshmy0ifvxh7f8wq53vk5x7vd13-source
new file mode 120000
index 00000000..d5db22c1
--- /dev/null
+++ b/.direnv/flake-inputs/llgwlxshmy0ifvxh7f8wq53vk5x7vd13-source
@@ -0,0 +1 @@
+/nix/store/llgwlxshmy0ifvxh7f8wq53vk5x7vd13-source \ No newline at end of file
diff --git a/.direnv/flake-profile-a5d5b61aa8a61b7d9d765e1daf971a9a578f1cfa b/.direnv/flake-profile-a5d5b61aa8a61b7d9d765e1daf971a9a578f1cfa
new file mode 120000
index 00000000..44319074
--- /dev/null
+++ b/.direnv/flake-profile-a5d5b61aa8a61b7d9d765e1daf971a9a578f1cfa
@@ -0,0 +1 @@
+/nix/store/sbx3dzavjwvfdiwhs1y38j2bzipnk3z7-nix-shell-env \ No newline at end of file
diff --git a/.direnv/flake-profile-a5d5b61aa8a61b7d9d765e1daf971a9a578f1cfa.rc b/.direnv/flake-profile-a5d5b61aa8a61b7d9d765e1daf971a9a578f1cfa.rc
new file mode 100644
index 00000000..1d0b881c
--- /dev/null
+++ b/.direnv/flake-profile-a5d5b61aa8a61b7d9d765e1daf971a9a578f1cfa.rc
@@ -0,0 +1,2340 @@
+unset shellHook
+PATH=${PATH:-}
+nix_saved_PATH="$PATH"
+XDG_DATA_DIRS=${XDG_DATA_DIRS:-}
+nix_saved_XDG_DATA_DIRS="$XDG_DATA_DIRS"
+AR='ar'
+export AR
+AS='as'
+export AS
+BASH='/nix/store/c9mv5v53f7vy3wd00ah0f77k509mzmn7-bash-5.3p15/bin/bash'
+CC='gcc'
+export CC
+CMAKE_INCLUDE_PATH='/nix/store/x98gls54ki3fmm2pv2cmi6z8mcda6glk-nodejs-24.18.0/include:/nix/store/g6ny28cwi10vsjb76r671nhzfyln3mp6-alsa-lib-1.2.16.1-dev/include:/nix/store/lp5dqcflpn6wnwnxcrv39yjsn45pwpys-fontconfig-2.18.1-dev/include:/nix/store/qmajm5vxiwziaw4d34d5mwiy39d65wj4-freetype-2.14.3-dev/include:/nix/store/ydgdz8pf2in3rlb5agwkgr76vfrdf2s5-zlib-1.3.2-dev/include:/nix/store/g9233lb7899rdmaw3gr6jnc35g8jxhmi-bzip2-1.0.8-dev/include:/nix/store/xkjdypjdb61dqq0z79fdqd1mjhw3k3vi-brotli-1.2.0-dev/include:/nix/store/c1zcwag5x9i24jj9nxv3dfn35ng33yn3-libpng-apng-1.6.58-dev/include:/nix/store/g0nnb14m078jyiwlhfmvj2jckz0cgmgx-expat-2.8.2-dev/include:/nix/store/gxwk0j6mpbm0xav65bhdi3adfy1n9pr9-libxkbcommon-1.13.2-dev/include:/nix/store/5iga06c6w0pb6p1srgkxzjvrzgf93j1y-openssl-3.6.3-dev/include:/nix/store/0f7v73wfccb594s137p30fjj5gcpkwvx-wayland-1.26.0-dev/include:/nix/store/f7kgxfrak6yngbzj7gkzx7v7mq59fcml-libx11-1.8.13-dev/include:/nix/store/cnf77zjffsdyncsm7q2kgcsfi21hvbd8-xorgproto-2025.1/include:/nix/store/7w2z7kbvifnwg57divcmygmysqwa59s2-libxcb-1.17.0-dev/include:/nix/store/wpkzgp91zkc9amia9qwsdmmcnpvsyacx-ffmpeg-headless-8.1.2-dev/include:/nix/store/1428qld56fkn2mjdjg6l4ky96idiynx4-clang-21.1.8-dev/include'
+export CMAKE_INCLUDE_PATH
+CMAKE_LIBRARY_PATH='/nix/store/3dbrmhhns34l05k4640ag3ckdk53b5x5-alsa-lib-1.2.16.1/lib:/nix/store/78x9i5x1wpqw4kq0h39b8f35abcv156h-zlib-1.3.2/lib:/nix/store/w7dr4vs79wc59bqpx5r1nj4i8gachbwb-bzip2-1.0.8/lib:/nix/store/pyjdn8k7hsfydg6nh53gc75hndknl6nq-brotli-1.2.0-lib/lib:/nix/store/d98p1fdc3rcsjlyj7pikc9qp67z9y2jy-libpng-apng-1.6.58/lib:/nix/store/4ysy3x46fpac2kl7b7siyb04fzqymmqy-freetype-2.14.3/lib:/nix/store/g264nm7psb4b9w31kqxqfnysv376hc9n-expat-2.8.2/lib:/nix/store/l9i81aw8mlp1mddq9ayy8m5iyi71s94z-fontconfig-2.18.1-lib/lib:/nix/store/bn21gzsggp8dvzfizi3nhhmz0s555ybq-libxkbcommon-1.13.2/lib:/nix/store/wn3g9b8f6m2z4ig76lipaijxgkf0pi1m-openssl-3.6.3/lib:/nix/store/yccaj0crcsng6hp71l537s5a6llbgrm0-vulkan-loader-1.4.350.0/lib:/nix/store/msjil83s4z6f0h373c8q4fm7rkhjax46-wayland-1.26.0/lib:/nix/store/45naqds5dkzsmmrh61wbxbfci73san7n-libx11-1.8.13/lib:/nix/store/2chpcgwndk5iphqgwf9r7x4yjysmkd2z-libxcb-1.17.0/lib:/nix/store/ppx0qjcsjrl0xxs1z6w094f4xnzmaybf-ffmpeg-headless-8.1.2-lib/lib:/nix/store/63vszz1y8lw6xqjipsw6bh56105nw894-clang-21.1.8-lib/lib'
+export CMAKE_LIBRARY_PATH
+CONFIG_SHELL='/nix/store/c9mv5v53f7vy3wd00ah0f77k509mzmn7-bash-5.3p15/bin/bash'
+export CONFIG_SHELL
+CXX='g++'
+export CXX
+HOSTTYPE='x86_64'
+HOST_PATH='/nix/store/mp8s10fwm685azvvv1qq7zyf7iajjlj8-coreutils-9.11/bin:/nix/store/m1p6gxgxis75rn0d549ny2y8gpxjv9pd-findutils-4.10.0/bin:/nix/store/bx0a1ijcg9ss307lrizi1cavyfs4s2g5-diffutils-3.12/bin:/nix/store/x2kfd3hnhqk8d6kxkyffpk7lyaz8iy76-gnused-4.10/bin:/nix/store/xj9dgyqrcq8hrf4mrkvbcp4pa3hgrbhy-gnugrep-3.12/bin:/nix/store/0fpnmyzv3jv5a3q3p3f08hfmy2qrhmnj-gawk-5.4.1/bin:/nix/store/j5r2kzhzb2x8avna8rb21xq7dxrrr6wv-gnutar-1.35/bin:/nix/store/5chmw9ib7xz2f4rw6bgdwxx0vb9bqqpa-gzip-1.14/bin:/nix/store/mbakq7rmq5sf04xljvvaj19d5xgzr1zr-bzip2-1.0.8-bin/bin:/nix/store/xrj3v3k9v5hvx3vl0jgfp5if3syqgh8g-gnumake-4.4.1/bin:/nix/store/c9mv5v53f7vy3wd00ah0f77k509mzmn7-bash-5.3p15/bin:/nix/store/xbdvi1a0mqzml869i0123pylhgjdyqi5-patch-2.8/bin:/nix/store/6gkcds8g0vi54xrmxpyv58r8x2fvqw79-xz-5.8.3-bin/bin:/nix/store/w5rsmvy2fcp7lrnn6y73zgf1rsjk6wn0-file-5.48/bin'
+export HOST_PATH
+IFS=' '$'\x09'''$'\x0a'''
+IN_NIX_SHELL='impure'
+export IN_NIX_SHELL
+LD='ld'
+export LD
+LIBCLANG_PATH='/nix/store/63vszz1y8lw6xqjipsw6bh56105nw894-clang-21.1.8-lib/lib'
+export LIBCLANG_PATH
+LINENO='101'
+MACHTYPE='x86_64-pc-linux-gnu'
+NIXPKGS_CMAKE_PREFIX_PATH='/nix/store/fs2axbvhmim3y1dwzpf5mh56569h15df-bun-1.3.13:/nix/store/70h070nb8c1l202a4gn58n6mxapbjhl9-just-1.57.0:/nix/store/x98gls54ki3fmm2pv2cmi6z8mcda6glk-nodejs-24.18.0:/nix/store/l7v00m55rb130bhql2kr74pq8sdpl41c-cargo-1.97.0:/nix/store/bijimpgbih6bwc8sykr5fijcjim2qfgr-clippy-1.97.0:/nix/store/h6c2mbpmxih3mfadhvl6jzf090n419yv-rustc-wrapper-1.97.0:/nix/store/i7qpcas4xd1kz9k98yh2c0w8ld0x288b-rustfmt-1.97.0:/nix/store/fsfnv7a0fbsxh6h7smx9phhp3f2nrx8j-cmake-4.3.4:/nix/store/swljvs2gz4mcb1avyjzb0cc2qrbslmbb-pkg-config-wrapper-0.29.2:/nix/store/g6ny28cwi10vsjb76r671nhzfyln3mp6-alsa-lib-1.2.16.1-dev:/nix/store/3dbrmhhns34l05k4640ag3ckdk53b5x5-alsa-lib-1.2.16.1:/nix/store/lp5dqcflpn6wnwnxcrv39yjsn45pwpys-fontconfig-2.18.1-dev:/nix/store/qmajm5vxiwziaw4d34d5mwiy39d65wj4-freetype-2.14.3-dev:/nix/store/ydgdz8pf2in3rlb5agwkgr76vfrdf2s5-zlib-1.3.2-dev:/nix/store/78x9i5x1wpqw4kq0h39b8f35abcv156h-zlib-1.3.2:/nix/store/g9233lb7899rdmaw3gr6jnc35g8jxhmi-bzip2-1.0.8-dev:/nix/store/mbakq7rmq5sf04xljvvaj19d5xgzr1zr-bzip2-1.0.8-bin:/nix/store/w7dr4vs79wc59bqpx5r1nj4i8gachbwb-bzip2-1.0.8:/nix/store/xkjdypjdb61dqq0z79fdqd1mjhw3k3vi-brotli-1.2.0-dev:/nix/store/pyjdn8k7hsfydg6nh53gc75hndknl6nq-brotli-1.2.0-lib:/nix/store/4lxy2qign56dcbcwd202h6f44m2p0fcq-brotli-1.2.0:/nix/store/c1zcwag5x9i24jj9nxv3dfn35ng33yn3-libpng-apng-1.6.58-dev:/nix/store/d98p1fdc3rcsjlyj7pikc9qp67z9y2jy-libpng-apng-1.6.58:/nix/store/4ysy3x46fpac2kl7b7siyb04fzqymmqy-freetype-2.14.3:/nix/store/g0nnb14m078jyiwlhfmvj2jckz0cgmgx-expat-2.8.2-dev:/nix/store/g264nm7psb4b9w31kqxqfnysv376hc9n-expat-2.8.2:/nix/store/1l2q64dwza6bd5nzrz3wwp5xjh0awv6n-fontconfig-2.18.1-bin:/nix/store/l9i81aw8mlp1mddq9ayy8m5iyi71s94z-fontconfig-2.18.1-lib:/nix/store/gxwk0j6mpbm0xav65bhdi3adfy1n9pr9-libxkbcommon-1.13.2-dev:/nix/store/bn21gzsggp8dvzfizi3nhhmz0s555ybq-libxkbcommon-1.13.2:/nix/store/5iga06c6w0pb6p1srgkxzjvrzgf93j1y-openssl-3.6.3-dev:/nix/store/g7xz6l9lp0w3fh0c4xralli79vd2vzpi-openssl-3.6.3-bin:/nix/store/wn3g9b8f6m2z4ig76lipaijxgkf0pi1m-openssl-3.6.3:/nix/store/paydkfngzxsg56vnvzb6xgcd4zfkfjab-vulkan-loader-1.4.350.0-dev:/nix/store/yccaj0crcsng6hp71l537s5a6llbgrm0-vulkan-loader-1.4.350.0:/nix/store/0f7v73wfccb594s137p30fjj5gcpkwvx-wayland-1.26.0-dev:/nix/store/msjil83s4z6f0h373c8q4fm7rkhjax46-wayland-1.26.0:/nix/store/f7kgxfrak6yngbzj7gkzx7v7mq59fcml-libx11-1.8.13-dev:/nix/store/cnf77zjffsdyncsm7q2kgcsfi21hvbd8-xorgproto-2025.1:/nix/store/45naqds5dkzsmmrh61wbxbfci73san7n-libx11-1.8.13:/nix/store/7w2z7kbvifnwg57divcmygmysqwa59s2-libxcb-1.17.0-dev:/nix/store/2chpcgwndk5iphqgwf9r7x4yjysmkd2z-libxcb-1.17.0:/nix/store/wpkzgp91zkc9amia9qwsdmmcnpvsyacx-ffmpeg-headless-8.1.2-dev:/nix/store/8a1pp3k9dy1lk8841b50mg0rcfcikcjx-ffmpeg-headless-8.1.2-bin:/nix/store/ppx0qjcsjrl0xxs1z6w094f4xnzmaybf-ffmpeg-headless-8.1.2-lib:/nix/store/1428qld56fkn2mjdjg6l4ky96idiynx4-clang-21.1.8-dev:/nix/store/63vszz1y8lw6xqjipsw6bh56105nw894-clang-21.1.8-lib:/nix/store/546z2n8r63az6sbi68gddc9pz62p7mxa-clang-21.1.8:/nix/store/f13cgj4sypbjrwwpshfc2x4fd11is8dp-patchelf-0.15.2:/nix/store/wkyl6bvcp2dcw1in5qhqrq1dbvrwi193-update-autotools-gnu-config-scripts-hook:/nix/store/adcz0m6qq2flmshdf0zz2xwjr5zbq1gr-gcc-wrapper-15.3.0:/nix/store/9q284bcg9jr5hd5537i7v9n2ngqm1dq8-binutils-wrapper-2.46'
+export NIXPKGS_CMAKE_PREFIX_PATH
+NIX_BINTOOLS='/nix/store/9q284bcg9jr5hd5537i7v9n2ngqm1dq8-binutils-wrapper-2.46'
+export NIX_BINTOOLS
+NIX_BINTOOLS_WRAPPER_TARGET_HOST_x86_64_unknown_linux_gnu='1'
+export NIX_BINTOOLS_WRAPPER_TARGET_HOST_x86_64_unknown_linux_gnu
+NIX_BUILD_CORES='28'
+export NIX_BUILD_CORES
+NIX_CC='/nix/store/adcz0m6qq2flmshdf0zz2xwjr5zbq1gr-gcc-wrapper-15.3.0'
+export NIX_CC
+NIX_CC_WRAPPER_TARGET_HOST_x86_64_unknown_linux_gnu='1'
+export NIX_CC_WRAPPER_TARGET_HOST_x86_64_unknown_linux_gnu
+NIX_CFLAGS_COMPILE=' -frandom-seed=sbx3dzavjw -isystem /nix/store/x98gls54ki3fmm2pv2cmi6z8mcda6glk-nodejs-24.18.0/include -isystem /nix/store/g6ny28cwi10vsjb76r671nhzfyln3mp6-alsa-lib-1.2.16.1-dev/include -isystem /nix/store/lp5dqcflpn6wnwnxcrv39yjsn45pwpys-fontconfig-2.18.1-dev/include -isystem /nix/store/qmajm5vxiwziaw4d34d5mwiy39d65wj4-freetype-2.14.3-dev/include -isystem /nix/store/ydgdz8pf2in3rlb5agwkgr76vfrdf2s5-zlib-1.3.2-dev/include -isystem /nix/store/g9233lb7899rdmaw3gr6jnc35g8jxhmi-bzip2-1.0.8-dev/include -isystem /nix/store/xkjdypjdb61dqq0z79fdqd1mjhw3k3vi-brotli-1.2.0-dev/include -isystem /nix/store/c1zcwag5x9i24jj9nxv3dfn35ng33yn3-libpng-apng-1.6.58-dev/include -isystem /nix/store/g0nnb14m078jyiwlhfmvj2jckz0cgmgx-expat-2.8.2-dev/include -isystem /nix/store/gxwk0j6mpbm0xav65bhdi3adfy1n9pr9-libxkbcommon-1.13.2-dev/include -isystem /nix/store/5iga06c6w0pb6p1srgkxzjvrzgf93j1y-openssl-3.6.3-dev/include -isystem /nix/store/0f7v73wfccb594s137p30fjj5gcpkwvx-wayland-1.26.0-dev/include -isystem /nix/store/f7kgxfrak6yngbzj7gkzx7v7mq59fcml-libx11-1.8.13-dev/include -isystem /nix/store/cnf77zjffsdyncsm7q2kgcsfi21hvbd8-xorgproto-2025.1/include -isystem /nix/store/7w2z7kbvifnwg57divcmygmysqwa59s2-libxcb-1.17.0-dev/include -isystem /nix/store/wpkzgp91zkc9amia9qwsdmmcnpvsyacx-ffmpeg-headless-8.1.2-dev/include -isystem /nix/store/1428qld56fkn2mjdjg6l4ky96idiynx4-clang-21.1.8-dev/include -isystem /nix/store/x98gls54ki3fmm2pv2cmi6z8mcda6glk-nodejs-24.18.0/include -isystem /nix/store/g6ny28cwi10vsjb76r671nhzfyln3mp6-alsa-lib-1.2.16.1-dev/include -isystem /nix/store/lp5dqcflpn6wnwnxcrv39yjsn45pwpys-fontconfig-2.18.1-dev/include -isystem /nix/store/qmajm5vxiwziaw4d34d5mwiy39d65wj4-freetype-2.14.3-dev/include -isystem /nix/store/ydgdz8pf2in3rlb5agwkgr76vfrdf2s5-zlib-1.3.2-dev/include -isystem /nix/store/g9233lb7899rdmaw3gr6jnc35g8jxhmi-bzip2-1.0.8-dev/include -isystem /nix/store/xkjdypjdb61dqq0z79fdqd1mjhw3k3vi-brotli-1.2.0-dev/include -isystem /nix/store/c1zcwag5x9i24jj9nxv3dfn35ng33yn3-libpng-apng-1.6.58-dev/include -isystem /nix/store/g0nnb14m078jyiwlhfmvj2jckz0cgmgx-expat-2.8.2-dev/include -isystem /nix/store/gxwk0j6mpbm0xav65bhdi3adfy1n9pr9-libxkbcommon-1.13.2-dev/include -isystem /nix/store/5iga06c6w0pb6p1srgkxzjvrzgf93j1y-openssl-3.6.3-dev/include -isystem /nix/store/0f7v73wfccb594s137p30fjj5gcpkwvx-wayland-1.26.0-dev/include -isystem /nix/store/f7kgxfrak6yngbzj7gkzx7v7mq59fcml-libx11-1.8.13-dev/include -isystem /nix/store/cnf77zjffsdyncsm7q2kgcsfi21hvbd8-xorgproto-2025.1/include -isystem /nix/store/7w2z7kbvifnwg57divcmygmysqwa59s2-libxcb-1.17.0-dev/include -isystem /nix/store/wpkzgp91zkc9amia9qwsdmmcnpvsyacx-ffmpeg-headless-8.1.2-dev/include -isystem /nix/store/1428qld56fkn2mjdjg6l4ky96idiynx4-clang-21.1.8-dev/include'
+export NIX_CFLAGS_COMPILE
+NIX_ENFORCE_NO_NATIVE='1'
+export NIX_ENFORCE_NO_NATIVE
+NIX_HARDENING_ENABLE='bindnow format fortify fortify3 libcxxhardeningfast pic relro stackclashprotection stackprotector strictflexarrays1 strictoverflow zerocallusedregs'
+export NIX_HARDENING_ENABLE
+NIX_LDFLAGS='-rpath /home/cashmere/opencut-classic/outputs/out/lib -L/nix/store/3dbrmhhns34l05k4640ag3ckdk53b5x5-alsa-lib-1.2.16.1/lib -L/nix/store/78x9i5x1wpqw4kq0h39b8f35abcv156h-zlib-1.3.2/lib -L/nix/store/w7dr4vs79wc59bqpx5r1nj4i8gachbwb-bzip2-1.0.8/lib -L/nix/store/pyjdn8k7hsfydg6nh53gc75hndknl6nq-brotli-1.2.0-lib/lib -L/nix/store/d98p1fdc3rcsjlyj7pikc9qp67z9y2jy-libpng-apng-1.6.58/lib -L/nix/store/4ysy3x46fpac2kl7b7siyb04fzqymmqy-freetype-2.14.3/lib -L/nix/store/g264nm7psb4b9w31kqxqfnysv376hc9n-expat-2.8.2/lib -L/nix/store/l9i81aw8mlp1mddq9ayy8m5iyi71s94z-fontconfig-2.18.1-lib/lib -L/nix/store/bn21gzsggp8dvzfizi3nhhmz0s555ybq-libxkbcommon-1.13.2/lib -L/nix/store/wn3g9b8f6m2z4ig76lipaijxgkf0pi1m-openssl-3.6.3/lib -L/nix/store/yccaj0crcsng6hp71l537s5a6llbgrm0-vulkan-loader-1.4.350.0/lib -L/nix/store/msjil83s4z6f0h373c8q4fm7rkhjax46-wayland-1.26.0/lib -L/nix/store/45naqds5dkzsmmrh61wbxbfci73san7n-libx11-1.8.13/lib -L/nix/store/2chpcgwndk5iphqgwf9r7x4yjysmkd2z-libxcb-1.17.0/lib -L/nix/store/ppx0qjcsjrl0xxs1z6w094f4xnzmaybf-ffmpeg-headless-8.1.2-lib/lib -L/nix/store/63vszz1y8lw6xqjipsw6bh56105nw894-clang-21.1.8-lib/lib -L/nix/store/3dbrmhhns34l05k4640ag3ckdk53b5x5-alsa-lib-1.2.16.1/lib -L/nix/store/78x9i5x1wpqw4kq0h39b8f35abcv156h-zlib-1.3.2/lib -L/nix/store/w7dr4vs79wc59bqpx5r1nj4i8gachbwb-bzip2-1.0.8/lib -L/nix/store/pyjdn8k7hsfydg6nh53gc75hndknl6nq-brotli-1.2.0-lib/lib -L/nix/store/d98p1fdc3rcsjlyj7pikc9qp67z9y2jy-libpng-apng-1.6.58/lib -L/nix/store/4ysy3x46fpac2kl7b7siyb04fzqymmqy-freetype-2.14.3/lib -L/nix/store/g264nm7psb4b9w31kqxqfnysv376hc9n-expat-2.8.2/lib -L/nix/store/l9i81aw8mlp1mddq9ayy8m5iyi71s94z-fontconfig-2.18.1-lib/lib -L/nix/store/bn21gzsggp8dvzfizi3nhhmz0s555ybq-libxkbcommon-1.13.2/lib -L/nix/store/wn3g9b8f6m2z4ig76lipaijxgkf0pi1m-openssl-3.6.3/lib -L/nix/store/yccaj0crcsng6hp71l537s5a6llbgrm0-vulkan-loader-1.4.350.0/lib -L/nix/store/msjil83s4z6f0h373c8q4fm7rkhjax46-wayland-1.26.0/lib -L/nix/store/45naqds5dkzsmmrh61wbxbfci73san7n-libx11-1.8.13/lib -L/nix/store/2chpcgwndk5iphqgwf9r7x4yjysmkd2z-libxcb-1.17.0/lib -L/nix/store/ppx0qjcsjrl0xxs1z6w094f4xnzmaybf-ffmpeg-headless-8.1.2-lib/lib -L/nix/store/63vszz1y8lw6xqjipsw6bh56105nw894-clang-21.1.8-lib/lib'
+export NIX_LDFLAGS
+NIX_NO_SELF_RPATH='1'
+NIX_PKG_CONFIG_WRAPPER_TARGET_HOST_x86_64_unknown_linux_gnu='1'
+export NIX_PKG_CONFIG_WRAPPER_TARGET_HOST_x86_64_unknown_linux_gnu
+NIX_STORE='/nix/store'
+export NIX_STORE
+NM='nm'
+export NM
+OBJCOPY='objcopy'
+export OBJCOPY
+OBJDUMP='objdump'
+export OBJDUMP
+OLDPWD=''
+export OLDPWD
+OPTERR='1'
+OSTYPE='linux-gnu'
+PATH='/nix/store/fs2axbvhmim3y1dwzpf5mh56569h15df-bun-1.3.13/bin:/nix/store/70h070nb8c1l202a4gn58n6mxapbjhl9-just-1.57.0/bin:/nix/store/x98gls54ki3fmm2pv2cmi6z8mcda6glk-nodejs-24.18.0/bin:/nix/store/l7v00m55rb130bhql2kr74pq8sdpl41c-cargo-1.97.0/bin:/nix/store/bijimpgbih6bwc8sykr5fijcjim2qfgr-clippy-1.97.0/bin:/nix/store/h6c2mbpmxih3mfadhvl6jzf090n419yv-rustc-wrapper-1.97.0/bin:/nix/store/i7qpcas4xd1kz9k98yh2c0w8ld0x288b-rustfmt-1.97.0/bin:/nix/store/fsfnv7a0fbsxh6h7smx9phhp3f2nrx8j-cmake-4.3.4/bin:/nix/store/swljvs2gz4mcb1avyjzb0cc2qrbslmbb-pkg-config-wrapper-0.29.2/bin:/nix/store/3dbrmhhns34l05k4640ag3ckdk53b5x5-alsa-lib-1.2.16.1/bin:/nix/store/qmajm5vxiwziaw4d34d5mwiy39d65wj4-freetype-2.14.3-dev/bin:/nix/store/mbakq7rmq5sf04xljvvaj19d5xgzr1zr-bzip2-1.0.8-bin/bin:/nix/store/4lxy2qign56dcbcwd202h6f44m2p0fcq-brotli-1.2.0/bin:/nix/store/c1zcwag5x9i24jj9nxv3dfn35ng33yn3-libpng-apng-1.6.58-dev/bin:/nix/store/g0nnb14m078jyiwlhfmvj2jckz0cgmgx-expat-2.8.2-dev/bin:/nix/store/1l2q64dwza6bd5nzrz3wwp5xjh0awv6n-fontconfig-2.18.1-bin/bin:/nix/store/bn21gzsggp8dvzfizi3nhhmz0s555ybq-libxkbcommon-1.13.2/bin:/nix/store/g7xz6l9lp0w3fh0c4xralli79vd2vzpi-openssl-3.6.3-bin/bin:/nix/store/8a1pp3k9dy1lk8841b50mg0rcfcikcjx-ffmpeg-headless-8.1.2-bin/bin:/nix/store/1428qld56fkn2mjdjg6l4ky96idiynx4-clang-21.1.8-dev/bin:/nix/store/546z2n8r63az6sbi68gddc9pz62p7mxa-clang-21.1.8/bin:/nix/store/f13cgj4sypbjrwwpshfc2x4fd11is8dp-patchelf-0.15.2/bin:/nix/store/adcz0m6qq2flmshdf0zz2xwjr5zbq1gr-gcc-wrapper-15.3.0/bin:/nix/store/cmjirs6g57ddbyjglwic2g2cjrwy3ppr-gcc-15.3.0/bin:/nix/store/wnkmdd3nm0af5lxgwq1h4v52y6mdl4vj-glibc-2.42-67-bin/bin:/nix/store/mp8s10fwm685azvvv1qq7zyf7iajjlj8-coreutils-9.11/bin:/nix/store/9q284bcg9jr5hd5537i7v9n2ngqm1dq8-binutils-wrapper-2.46/bin:/nix/store/b01rwyg0ii5v8r50yrikcs5af64x0v0q-binutils-2.46/bin:/nix/store/mp8s10fwm685azvvv1qq7zyf7iajjlj8-coreutils-9.11/bin:/nix/store/m1p6gxgxis75rn0d549ny2y8gpxjv9pd-findutils-4.10.0/bin:/nix/store/bx0a1ijcg9ss307lrizi1cavyfs4s2g5-diffutils-3.12/bin:/nix/store/x2kfd3hnhqk8d6kxkyffpk7lyaz8iy76-gnused-4.10/bin:/nix/store/xj9dgyqrcq8hrf4mrkvbcp4pa3hgrbhy-gnugrep-3.12/bin:/nix/store/0fpnmyzv3jv5a3q3p3f08hfmy2qrhmnj-gawk-5.4.1/bin:/nix/store/j5r2kzhzb2x8avna8rb21xq7dxrrr6wv-gnutar-1.35/bin:/nix/store/5chmw9ib7xz2f4rw6bgdwxx0vb9bqqpa-gzip-1.14/bin:/nix/store/mbakq7rmq5sf04xljvvaj19d5xgzr1zr-bzip2-1.0.8-bin/bin:/nix/store/xrj3v3k9v5hvx3vl0jgfp5if3syqgh8g-gnumake-4.4.1/bin:/nix/store/c9mv5v53f7vy3wd00ah0f77k509mzmn7-bash-5.3p15/bin:/nix/store/xbdvi1a0mqzml869i0123pylhgjdyqi5-patch-2.8/bin:/nix/store/6gkcds8g0vi54xrmxpyv58r8x2fvqw79-xz-5.8.3-bin/bin:/nix/store/w5rsmvy2fcp7lrnn6y73zgf1rsjk6wn0-file-5.48/bin'
+export PATH
+PKG_CONFIG='pkg-config'
+export PKG_CONFIG
+PKG_CONFIG_PATH='/nix/store/g6ny28cwi10vsjb76r671nhzfyln3mp6-alsa-lib-1.2.16.1-dev/lib/pkgconfig:/nix/store/lp5dqcflpn6wnwnxcrv39yjsn45pwpys-fontconfig-2.18.1-dev/lib/pkgconfig:/nix/store/qmajm5vxiwziaw4d34d5mwiy39d65wj4-freetype-2.14.3-dev/lib/pkgconfig:/nix/store/ydgdz8pf2in3rlb5agwkgr76vfrdf2s5-zlib-1.3.2-dev/share/pkgconfig:/nix/store/g9233lb7899rdmaw3gr6jnc35g8jxhmi-bzip2-1.0.8-dev/lib/pkgconfig:/nix/store/xkjdypjdb61dqq0z79fdqd1mjhw3k3vi-brotli-1.2.0-dev/lib/pkgconfig:/nix/store/c1zcwag5x9i24jj9nxv3dfn35ng33yn3-libpng-apng-1.6.58-dev/lib/pkgconfig:/nix/store/g0nnb14m078jyiwlhfmvj2jckz0cgmgx-expat-2.8.2-dev/lib/pkgconfig:/nix/store/gxwk0j6mpbm0xav65bhdi3adfy1n9pr9-libxkbcommon-1.13.2-dev/lib/pkgconfig:/nix/store/5iga06c6w0pb6p1srgkxzjvrzgf93j1y-openssl-3.6.3-dev/lib/pkgconfig:/nix/store/paydkfngzxsg56vnvzb6xgcd4zfkfjab-vulkan-loader-1.4.350.0-dev/lib/pkgconfig:/nix/store/0f7v73wfccb594s137p30fjj5gcpkwvx-wayland-1.26.0-dev/lib/pkgconfig:/nix/store/f7kgxfrak6yngbzj7gkzx7v7mq59fcml-libx11-1.8.13-dev/lib/pkgconfig:/nix/store/cnf77zjffsdyncsm7q2kgcsfi21hvbd8-xorgproto-2025.1/share/pkgconfig:/nix/store/7w2z7kbvifnwg57divcmygmysqwa59s2-libxcb-1.17.0-dev/lib/pkgconfig:/nix/store/wpkzgp91zkc9amia9qwsdmmcnpvsyacx-ffmpeg-headless-8.1.2-dev/lib/pkgconfig'
+export PKG_CONFIG_PATH
+PS4='+ '
+RANLIB='ranlib'
+export RANLIB
+READELF='readelf'
+export READELF
+SHELL='/nix/store/c9mv5v53f7vy3wd00ah0f77k509mzmn7-bash-5.3p15/bin/bash'
+export SHELL
+SIZE='size'
+export SIZE
+SOURCE_DATE_EPOCH='315532800'
+export SOURCE_DATE_EPOCH
+STRINGS='strings'
+export STRINGS
+STRIP='strip'
+export STRIP
+XDG_DATA_DIRS='/nix/store/fs2axbvhmim3y1dwzpf5mh56569h15df-bun-1.3.13/share:/nix/store/70h070nb8c1l202a4gn58n6mxapbjhl9-just-1.57.0/share:/nix/store/x98gls54ki3fmm2pv2cmi6z8mcda6glk-nodejs-24.18.0/share:/nix/store/l7v00m55rb130bhql2kr74pq8sdpl41c-cargo-1.97.0/share:/nix/store/fsfnv7a0fbsxh6h7smx9phhp3f2nrx8j-cmake-4.3.4/share:/nix/store/swljvs2gz4mcb1avyjzb0cc2qrbslmbb-pkg-config-wrapper-0.29.2/share:/nix/store/g6ny28cwi10vsjb76r671nhzfyln3mp6-alsa-lib-1.2.16.1-dev/share:/nix/store/3dbrmhhns34l05k4640ag3ckdk53b5x5-alsa-lib-1.2.16.1/share:/nix/store/qmajm5vxiwziaw4d34d5mwiy39d65wj4-freetype-2.14.3-dev/share:/nix/store/ydgdz8pf2in3rlb5agwkgr76vfrdf2s5-zlib-1.3.2-dev/share:/nix/store/78x9i5x1wpqw4kq0h39b8f35abcv156h-zlib-1.3.2/share:/nix/store/4lxy2qign56dcbcwd202h6f44m2p0fcq-brotli-1.2.0/share:/nix/store/4ysy3x46fpac2kl7b7siyb04fzqymmqy-freetype-2.14.3/share:/nix/store/g0nnb14m078jyiwlhfmvj2jckz0cgmgx-expat-2.8.2-dev/share:/nix/store/g264nm7psb4b9w31kqxqfnysv376hc9n-expat-2.8.2/share:/nix/store/1l2q64dwza6bd5nzrz3wwp5xjh0awv6n-fontconfig-2.18.1-bin/share:/nix/store/l9i81aw8mlp1mddq9ayy8m5iyi71s94z-fontconfig-2.18.1-lib/share:/nix/store/bn21gzsggp8dvzfizi3nhhmz0s555ybq-libxkbcommon-1.13.2/share:/nix/store/cnf77zjffsdyncsm7q2kgcsfi21hvbd8-xorgproto-2025.1/share:/nix/store/45naqds5dkzsmmrh61wbxbfci73san7n-libx11-1.8.13/share:/nix/store/546z2n8r63az6sbi68gddc9pz62p7mxa-clang-21.1.8/share:/nix/store/f13cgj4sypbjrwwpshfc2x4fd11is8dp-patchelf-0.15.2/share'
+export XDG_DATA_DIRS
+__structuredAttrs=''
+export __structuredAttrs
+_substituteStream_has_warned_replace_deprecation='false'
+buildInputs=''
+export buildInputs
+buildPhase='{ echo "------------------------------------------------------------";'$'\x0a'' echo " WARNING: the existence of this path is not guaranteed.";'$'\x0a'' echo " It is an internal implementation detail for pkgs.mkShell.";'$'\x0a'' echo "------------------------------------------------------------";'$'\x0a'' echo;'$'\x0a'' # Record all build inputs as runtime dependencies'$'\x0a'' export;'$'\x0a''} >> "$out"'$'\x0a'''
+export buildPhase
+builder='/nix/store/c9mv5v53f7vy3wd00ah0f77k509mzmn7-bash-5.3p15/bin/bash'
+export builder
+checkPhase='justCheckPhase'
+cmakeFlags=''
+export cmakeFlags
+configureFlags=''
+export configureFlags
+configurePhase='cmakeConfigurePhase'
+defaultBuildInputs=''
+defaultNativeBuildInputs='/nix/store/f13cgj4sypbjrwwpshfc2x4fd11is8dp-patchelf-0.15.2 /nix/store/wkyl6bvcp2dcw1in5qhqrq1dbvrwi193-update-autotools-gnu-config-scripts-hook /nix/store/0y5xmdb7qfvimjwbq7ibg1xdgkgjwqng-no-broken-symlinks.sh /nix/store/cv1d7p48379km6a85h4zp6kr86brh32q-audit-tmpdir.sh /nix/store/85clx3b0xkdf58jn161iy80y5223ilbi-compress-man-pages.sh /nix/store/p3l1a5y7nllfyrjn2krlwgcc3z0cd3fq-make-symlinks-relative.sh /nix/store/5yzw0vhkyszf2d179m0qfkgxmp5wjjx4-move-docs.sh /nix/store/fyaryjvghbkpfnsyw97hb3lyb37s1pd6-move-lib64.sh /nix/store/kd4xwxjpjxi71jkm6ka0np72if9rm3y0-move-sbin.sh /nix/store/pag6l61paj1dc9sv15l7bm5c17xn5kyk-move-systemd-user-units.sh /nix/store/cmzya9irvxzlkh7lfy6i82gbp0saxqj3-multiple-outputs.sh /nix/store/x8c40nfigps493a07sdr2pm5s9j1cdc0-patch-shebangs.sh /nix/store/cickvswrvann041nqxb0rxilc46svw1n-prune-libtool-files.sh /nix/store/xyff06pkhki3qy1ls77w10s0v79c9il0-reproducible-builds.sh /nix/store/z7k98578dfzi6l3hsvbivzm7hfqlk0zc-set-source-date-epoch-to-latest.sh /nix/store/pilsssjjdxvdphlg2h19p0bfx5q0jzkn-strip.sh /nix/store/adcz0m6qq2flmshdf0zz2xwjr5zbq1gr-gcc-wrapper-15.3.0'
+depsBuildBuild=''
+export depsBuildBuild
+depsBuildBuildPropagated=''
+export depsBuildBuildPropagated
+depsBuildTarget=''
+export depsBuildTarget
+depsBuildTargetPropagated=''
+export depsBuildTargetPropagated
+depsHostHost=''
+export depsHostHost
+depsHostHostPropagated=''
+export depsHostHostPropagated
+depsTargetTarget=''
+export depsTargetTarget
+depsTargetTargetPropagated=''
+export depsTargetTargetPropagated
+doCheck=''
+export doCheck
+doInstallCheck=''
+export doInstallCheck
+dontAddDisableDepTrack='1'
+export dontAddDisableDepTrack
+declare -a envBuildBuildHooks=()
+declare -a envBuildHostHooks=()
+declare -a envBuildTargetHooks=()
+declare -a envHostHostHooks=('addCMakeParams' 'pkgConfigWrapper_addPkgConfigPath' 'ccWrapper_addCVars' 'bintoolsWrapper_addLDVars' )
+declare -a envHostTargetHooks=('addCMakeParams' 'pkgConfigWrapper_addPkgConfigPath' 'ccWrapper_addCVars' 'bintoolsWrapper_addLDVars' )
+declare -a envTargetTargetHooks=()
+declare -a fixupOutputHooks=('if [ -z "${dontPatchELF-}" ]; then patchELF "$prefix"; fi' 'if [[ -z "${noAuditTmpdir-}" && -e "$prefix" ]]; then auditTmpdir "$prefix"; fi' 'if [ -z "${dontGzipMan-}" ]; then compressManPages "$prefix"; fi' '_moveLib64' '_moveSbin' '_moveSystemdUserUnits' 'patchShebangsAuto' '_pruneLibtoolFiles' '_doStrip' )
+flag='-L/nix/store/63vszz1y8lw6xqjipsw6bh56105nw894-clang-21.1.8-lib/lib'
+guess='28'
+iframework_seen=''
+initialPath='/nix/store/mp8s10fwm685azvvv1qq7zyf7iajjlj8-coreutils-9.11 /nix/store/m1p6gxgxis75rn0d549ny2y8gpxjv9pd-findutils-4.10.0 /nix/store/bx0a1ijcg9ss307lrizi1cavyfs4s2g5-diffutils-3.12 /nix/store/x2kfd3hnhqk8d6kxkyffpk7lyaz8iy76-gnused-4.10 /nix/store/xj9dgyqrcq8hrf4mrkvbcp4pa3hgrbhy-gnugrep-3.12 /nix/store/0fpnmyzv3jv5a3q3p3f08hfmy2qrhmnj-gawk-5.4.1 /nix/store/j5r2kzhzb2x8avna8rb21xq7dxrrr6wv-gnutar-1.35 /nix/store/5chmw9ib7xz2f4rw6bgdwxx0vb9bqqpa-gzip-1.14 /nix/store/mbakq7rmq5sf04xljvvaj19d5xgzr1zr-bzip2-1.0.8-bin /nix/store/xrj3v3k9v5hvx3vl0jgfp5if3syqgh8g-gnumake-4.4.1 /nix/store/c9mv5v53f7vy3wd00ah0f77k509mzmn7-bash-5.3p15 /nix/store/xbdvi1a0mqzml869i0123pylhgjdyqi5-patch-2.8 /nix/store/6gkcds8g0vi54xrmxpyv58r8x2fvqw79-xz-5.8.3-bin /nix/store/w5rsmvy2fcp7lrnn6y73zgf1rsjk6wn0-file-5.48'
+installPhase='justInstallPhase'
+isystem_seen=''
+mesonFlags=''
+export mesonFlags
+name='nix-shell-env'
+export name
+nativeBuildInputs='/nix/store/fs2axbvhmim3y1dwzpf5mh56569h15df-bun-1.3.13 /nix/store/70h070nb8c1l202a4gn58n6mxapbjhl9-just-1.57.0 /nix/store/x98gls54ki3fmm2pv2cmi6z8mcda6glk-nodejs-24.18.0 /nix/store/l7v00m55rb130bhql2kr74pq8sdpl41c-cargo-1.97.0 /nix/store/bijimpgbih6bwc8sykr5fijcjim2qfgr-clippy-1.97.0 /nix/store/h6c2mbpmxih3mfadhvl6jzf090n419yv-rustc-wrapper-1.97.0 /nix/store/i7qpcas4xd1kz9k98yh2c0w8ld0x288b-rustfmt-1.97.0 /nix/store/fsfnv7a0fbsxh6h7smx9phhp3f2nrx8j-cmake-4.3.4 /nix/store/swljvs2gz4mcb1avyjzb0cc2qrbslmbb-pkg-config-wrapper-0.29.2 /nix/store/g6ny28cwi10vsjb76r671nhzfyln3mp6-alsa-lib-1.2.16.1-dev /nix/store/lp5dqcflpn6wnwnxcrv39yjsn45pwpys-fontconfig-2.18.1-dev /nix/store/qmajm5vxiwziaw4d34d5mwiy39d65wj4-freetype-2.14.3-dev /nix/store/gxwk0j6mpbm0xav65bhdi3adfy1n9pr9-libxkbcommon-1.13.2-dev /nix/store/5iga06c6w0pb6p1srgkxzjvrzgf93j1y-openssl-3.6.3-dev /nix/store/paydkfngzxsg56vnvzb6xgcd4zfkfjab-vulkan-loader-1.4.350.0-dev /nix/store/0f7v73wfccb594s137p30fjj5gcpkwvx-wayland-1.26.0-dev /nix/store/f7kgxfrak6yngbzj7gkzx7v7mq59fcml-libx11-1.8.13-dev /nix/store/7w2z7kbvifnwg57divcmygmysqwa59s2-libxcb-1.17.0-dev /nix/store/wpkzgp91zkc9amia9qwsdmmcnpvsyacx-ffmpeg-headless-8.1.2-dev /nix/store/1428qld56fkn2mjdjg6l4ky96idiynx4-clang-21.1.8-dev'
+export nativeBuildInputs
+out='/home/cashmere/opencut-classic/outputs/out'
+export out
+outputBin='out'
+outputDev='out'
+outputDevdoc='REMOVE'
+outputDevman='out'
+outputDoc='out'
+outputInclude='out'
+outputInfo='out'
+outputLib='out'
+outputMan='out'
+outputs='out'
+export outputs
+patches=''
+export patches
+phases='buildPhase'
+export phases
+pkg='/nix/store/adcz0m6qq2flmshdf0zz2xwjr5zbq1gr-gcc-wrapper-15.3.0'
+declare -a pkgsBuildBuild=()
+declare -a pkgsBuildHost=('/nix/store/fs2axbvhmim3y1dwzpf5mh56569h15df-bun-1.3.13' '/nix/store/70h070nb8c1l202a4gn58n6mxapbjhl9-just-1.57.0' '/nix/store/x98gls54ki3fmm2pv2cmi6z8mcda6glk-nodejs-24.18.0' '/nix/store/l7v00m55rb130bhql2kr74pq8sdpl41c-cargo-1.97.0' '/nix/store/bijimpgbih6bwc8sykr5fijcjim2qfgr-clippy-1.97.0' '/nix/store/h6c2mbpmxih3mfadhvl6jzf090n419yv-rustc-wrapper-1.97.0' '/nix/store/i7qpcas4xd1kz9k98yh2c0w8ld0x288b-rustfmt-1.97.0' '/nix/store/fsfnv7a0fbsxh6h7smx9phhp3f2nrx8j-cmake-4.3.4' '/nix/store/swljvs2gz4mcb1avyjzb0cc2qrbslmbb-pkg-config-wrapper-0.29.2' '/nix/store/g6ny28cwi10vsjb76r671nhzfyln3mp6-alsa-lib-1.2.16.1-dev' '/nix/store/3dbrmhhns34l05k4640ag3ckdk53b5x5-alsa-lib-1.2.16.1' '/nix/store/lp5dqcflpn6wnwnxcrv39yjsn45pwpys-fontconfig-2.18.1-dev' '/nix/store/qmajm5vxiwziaw4d34d5mwiy39d65wj4-freetype-2.14.3-dev' '/nix/store/ydgdz8pf2in3rlb5agwkgr76vfrdf2s5-zlib-1.3.2-dev' '/nix/store/78x9i5x1wpqw4kq0h39b8f35abcv156h-zlib-1.3.2' '/nix/store/g9233lb7899rdmaw3gr6jnc35g8jxhmi-bzip2-1.0.8-dev' '/nix/store/mbakq7rmq5sf04xljvvaj19d5xgzr1zr-bzip2-1.0.8-bin' '/nix/store/w7dr4vs79wc59bqpx5r1nj4i8gachbwb-bzip2-1.0.8' '/nix/store/xkjdypjdb61dqq0z79fdqd1mjhw3k3vi-brotli-1.2.0-dev' '/nix/store/pyjdn8k7hsfydg6nh53gc75hndknl6nq-brotli-1.2.0-lib' '/nix/store/4lxy2qign56dcbcwd202h6f44m2p0fcq-brotli-1.2.0' '/nix/store/c1zcwag5x9i24jj9nxv3dfn35ng33yn3-libpng-apng-1.6.58-dev' '/nix/store/d98p1fdc3rcsjlyj7pikc9qp67z9y2jy-libpng-apng-1.6.58' '/nix/store/4ysy3x46fpac2kl7b7siyb04fzqymmqy-freetype-2.14.3' '/nix/store/g0nnb14m078jyiwlhfmvj2jckz0cgmgx-expat-2.8.2-dev' '/nix/store/g264nm7psb4b9w31kqxqfnysv376hc9n-expat-2.8.2' '/nix/store/1l2q64dwza6bd5nzrz3wwp5xjh0awv6n-fontconfig-2.18.1-bin' '/nix/store/l9i81aw8mlp1mddq9ayy8m5iyi71s94z-fontconfig-2.18.1-lib' '/nix/store/gxwk0j6mpbm0xav65bhdi3adfy1n9pr9-libxkbcommon-1.13.2-dev' '/nix/store/bn21gzsggp8dvzfizi3nhhmz0s555ybq-libxkbcommon-1.13.2' '/nix/store/5iga06c6w0pb6p1srgkxzjvrzgf93j1y-openssl-3.6.3-dev' '/nix/store/g7xz6l9lp0w3fh0c4xralli79vd2vzpi-openssl-3.6.3-bin' '/nix/store/wn3g9b8f6m2z4ig76lipaijxgkf0pi1m-openssl-3.6.3' '/nix/store/paydkfngzxsg56vnvzb6xgcd4zfkfjab-vulkan-loader-1.4.350.0-dev' '/nix/store/yccaj0crcsng6hp71l537s5a6llbgrm0-vulkan-loader-1.4.350.0' '/nix/store/0f7v73wfccb594s137p30fjj5gcpkwvx-wayland-1.26.0-dev' '/nix/store/msjil83s4z6f0h373c8q4fm7rkhjax46-wayland-1.26.0' '/nix/store/f7kgxfrak6yngbzj7gkzx7v7mq59fcml-libx11-1.8.13-dev' '/nix/store/cnf77zjffsdyncsm7q2kgcsfi21hvbd8-xorgproto-2025.1' '/nix/store/45naqds5dkzsmmrh61wbxbfci73san7n-libx11-1.8.13' '/nix/store/7w2z7kbvifnwg57divcmygmysqwa59s2-libxcb-1.17.0-dev' '/nix/store/2chpcgwndk5iphqgwf9r7x4yjysmkd2z-libxcb-1.17.0' '/nix/store/wpkzgp91zkc9amia9qwsdmmcnpvsyacx-ffmpeg-headless-8.1.2-dev' '/nix/store/8a1pp3k9dy1lk8841b50mg0rcfcikcjx-ffmpeg-headless-8.1.2-bin' '/nix/store/ppx0qjcsjrl0xxs1z6w094f4xnzmaybf-ffmpeg-headless-8.1.2-lib' '/nix/store/1428qld56fkn2mjdjg6l4ky96idiynx4-clang-21.1.8-dev' '/nix/store/63vszz1y8lw6xqjipsw6bh56105nw894-clang-21.1.8-lib' '/nix/store/546z2n8r63az6sbi68gddc9pz62p7mxa-clang-21.1.8' '/nix/store/f13cgj4sypbjrwwpshfc2x4fd11is8dp-patchelf-0.15.2' '/nix/store/wkyl6bvcp2dcw1in5qhqrq1dbvrwi193-update-autotools-gnu-config-scripts-hook' '/nix/store/0y5xmdb7qfvimjwbq7ibg1xdgkgjwqng-no-broken-symlinks.sh' '/nix/store/cv1d7p48379km6a85h4zp6kr86brh32q-audit-tmpdir.sh' '/nix/store/85clx3b0xkdf58jn161iy80y5223ilbi-compress-man-pages.sh' '/nix/store/p3l1a5y7nllfyrjn2krlwgcc3z0cd3fq-make-symlinks-relative.sh' '/nix/store/5yzw0vhkyszf2d179m0qfkgxmp5wjjx4-move-docs.sh' '/nix/store/fyaryjvghbkpfnsyw97hb3lyb37s1pd6-move-lib64.sh' '/nix/store/kd4xwxjpjxi71jkm6ka0np72if9rm3y0-move-sbin.sh' '/nix/store/pag6l61paj1dc9sv15l7bm5c17xn5kyk-move-systemd-user-units.sh' '/nix/store/cmzya9irvxzlkh7lfy6i82gbp0saxqj3-multiple-outputs.sh' '/nix/store/x8c40nfigps493a07sdr2pm5s9j1cdc0-patch-shebangs.sh' '/nix/store/cickvswrvann041nqxb0rxilc46svw1n-prune-libtool-files.sh' '/nix/store/xyff06pkhki3qy1ls77w10s0v79c9il0-reproducible-builds.sh' '/nix/store/z7k98578dfzi6l3hsvbivzm7hfqlk0zc-set-source-date-epoch-to-latest.sh' '/nix/store/pilsssjjdxvdphlg2h19p0bfx5q0jzkn-strip.sh' '/nix/store/adcz0m6qq2flmshdf0zz2xwjr5zbq1gr-gcc-wrapper-15.3.0' '/nix/store/9q284bcg9jr5hd5537i7v9n2ngqm1dq8-binutils-wrapper-2.46' )
+declare -a pkgsBuildTarget=()
+declare -a pkgsHostHost=()
+declare -a pkgsHostTarget=()
+declare -a pkgsTargetTarget=()
+declare -a postFixupHooks=('cmakePcfileCheckPhase' 'noBrokenSymlinksInAllOutputs' '_makeSymlinksRelative' '_multioutPropagateDev' )
+declare -a postHooks=('makeCmakeFindLibs' )
+declare -a postUnpackHooks=('_updateSourceDateEpochFromSourceRoot' )
+declare -a preConfigureHooks=('_multioutConfig' )
+preConfigurePhases=' updateAutotoolsGnuConfigScriptsPhase'
+declare -a preFixupHooks=('_moveToShare' '_multioutDocs' '_multioutDevs' )
+preferLocalBuild='1'
+export preferLocalBuild
+prefix='/home/cashmere/opencut-classic/outputs/out'
+declare -a propagatedBuildDepFiles=('propagated-build-build-deps' 'propagated-native-build-inputs' 'propagated-build-target-deps' )
+propagatedBuildInputs=''
+export propagatedBuildInputs
+declare -a propagatedHostDepFiles=('propagated-host-host-deps' 'propagated-build-inputs' )
+propagatedNativeBuildInputs=''
+export propagatedNativeBuildInputs
+declare -a propagatedTargetDepFiles=('propagated-target-target-deps' )
+setOutputFlags=''
+shell='/nix/store/c9mv5v53f7vy3wd00ah0f77k509mzmn7-bash-5.3p15/bin/bash'
+export shell
+shellHook='export BINDGEN_EXTRA_CLANG_ARGS="$(cat /nix/store/adcz0m6qq2flmshdf0zz2xwjr5zbq1gr-gcc-wrapper-15.3.0/nix-support/cc-cflags 2>/dev/null) $(cat /nix/store/adcz0m6qq2flmshdf0zz2xwjr5zbq1gr-gcc-wrapper-15.3.0/nix-support/libc-cflags 2>/dev/null) $BINDGEN_EXTRA_CLANG_ARGS"'$'\x0a''export LD_LIBRARY_PATH=/nix/store/3dbrmhhns34l05k4640ag3ckdk53b5x5-alsa-lib-1.2.16.1/lib:/nix/store/l9i81aw8mlp1mddq9ayy8m5iyi71s94z-fontconfig-2.18.1-lib/lib:/nix/store/4ysy3x46fpac2kl7b7siyb04fzqymmqy-freetype-2.14.3/lib:/nix/store/bn21gzsggp8dvzfizi3nhhmz0s555ybq-libxkbcommon-1.13.2/lib:/nix/store/yccaj0crcsng6hp71l537s5a6llbgrm0-vulkan-loader-1.4.350.0/lib:/nix/store/msjil83s4z6f0h373c8q4fm7rkhjax46-wayland-1.26.0/lib:/nix/store/45naqds5dkzsmmrh61wbxbfci73san7n-libx11-1.8.13/lib:/nix/store/2chpcgwndk5iphqgwf9r7x4yjysmkd2z-libxcb-1.17.0/lib:$LD_LIBRARY_PATH'$'\x0a'''
+export shellHook
+stdenv='/nix/store/sxx9r2badzg82107kiwwm9jmzlf52mdb-stdenv-linux'
+export stdenv
+strictDeps=''
+export strictDeps
+system='x86_64-linux'
+export system
+declare -a unpackCmdHooks=('_defaultUnpack' )
+_activatePkgs ()
+{
+
+ local hostOffset targetOffset;
+ local pkg;
+ for hostOffset in "${allPlatOffsets[@]}";
+ do
+ local pkgsVar="${pkgAccumVarVars[hostOffset + 1]}";
+ for targetOffset in "${allPlatOffsets[@]}";
+ do
+ (( hostOffset <= targetOffset )) || continue;
+ local pkgsRef="${pkgsVar}[$targetOffset - $hostOffset]";
+ local pkgsSlice="${!pkgsRef}[@]";
+ for pkg in ${!pkgsSlice+"${!pkgsSlice}"};
+ do
+ activatePackage "$pkg" "$hostOffset" "$targetOffset";
+ done;
+ done;
+ done
+}
+_addRpathPrefix ()
+{
+
+ if [ "${NIX_NO_SELF_RPATH:-0}" != 1 ]; then
+ export NIX_LDFLAGS="-rpath $1/lib ${NIX_LDFLAGS-}";
+ fi
+}
+_addToEnv ()
+{
+
+ local depHostOffset depTargetOffset;
+ local pkg;
+ for depHostOffset in "${allPlatOffsets[@]}";
+ do
+ local hookVar="${pkgHookVarVars[depHostOffset + 1]}";
+ local pkgsVar="${pkgAccumVarVars[depHostOffset + 1]}";
+ for depTargetOffset in "${allPlatOffsets[@]}";
+ do
+ (( depHostOffset <= depTargetOffset )) || continue;
+ local hookRef="${hookVar}[$depTargetOffset - $depHostOffset]";
+ if [[ -z "${strictDeps-}" ]]; then
+ local visitedPkgs="";
+ for pkg in "${pkgsBuildBuild[@]}" "${pkgsBuildHost[@]}" "${pkgsBuildTarget[@]}" "${pkgsHostHost[@]}" "${pkgsHostTarget[@]}" "${pkgsTargetTarget[@]}";
+ do
+ if [[ "$visitedPkgs" = *"$pkg"* ]]; then
+ continue;
+ fi;
+ runHook "${!hookRef}" "$pkg";
+ visitedPkgs+=" $pkg";
+ done;
+ else
+ local pkgsRef="${pkgsVar}[$depTargetOffset - $depHostOffset]";
+ local pkgsSlice="${!pkgsRef}[@]";
+ for pkg in ${!pkgsSlice+"${!pkgsSlice}"};
+ do
+ runHook "${!hookRef}" "$pkg";
+ done;
+ fi;
+ done;
+ done
+}
+_allFlags ()
+{
+
+ export system pname name version;
+ while IFS='' read -r varName; do
+ nixTalkativeLog "@${varName}@ -> ${!varName}";
+ args+=("--subst-var" "$varName");
+ done < <(awk 'BEGIN { for (v in ENVIRON) if (v ~ /^[a-z][a-zA-Z0-9_]*$/) print v }')
+}
+_assignFirst ()
+{
+
+ local varName="$1";
+ local _var;
+ local REMOVE=REMOVE;
+ shift;
+ for _var in "$@";
+ do
+ if [ -n "${!_var-}" ]; then
+ eval "${varName}"="${_var}";
+ return;
+ fi;
+ done;
+ echo;
+ echo "error: _assignFirst: could not find a non-empty variable whose name to assign to ${varName}.";
+ echo " The following variables were all unset or empty:";
+ echo " $*";
+ if [ -z "${out:-}" ]; then
+ echo ' If you do not want an "out" output in your derivation, make sure to define';
+ echo ' the other specific required outputs. This can be achieved by picking one';
+ echo " of the above as an output.";
+ echo ' You do not have to remove "out" if you want to have a different default';
+ echo ' output, because the first output is taken as a default.';
+ echo;
+ fi;
+ return 1
+}
+_callImplicitHook ()
+{
+
+ local def="$1";
+ local hookName="$2";
+ if declare -F "$hookName" > /dev/null; then
+ nixTalkativeLog "calling implicit '$hookName' function hook";
+ "$hookName";
+ else
+ if type -p "$hookName" > /dev/null; then
+ nixTalkativeLog "sourcing implicit '$hookName' script hook";
+ source "$hookName";
+ else
+ if [ -n "${!hookName:-}" ]; then
+ nixTalkativeLog "evaling implicit '$hookName' string hook";
+ eval "${!hookName}";
+ else
+ return "$def";
+ fi;
+ fi;
+ fi
+}
+_defaultUnpack ()
+{
+
+ local fn="$1";
+ local destination;
+ if [ -d "$fn" ]; then
+ destination="$(stripHash "$fn")";
+ if [ -e "$destination" ]; then
+ echo "Cannot copy $fn to $destination: destination already exists!";
+ echo "Did you specify two \"srcs\" with the same \"name\"?";
+ return 1;
+ fi;
+ cp -r --preserve=timestamps --reflink=auto -- "$fn" "$destination";
+ else
+ case "$fn" in
+ *.tar.xz | *.tar.lzma | *.txz)
+ ( XZ_OPT="--threads=$NIX_BUILD_CORES" xz -d < "$fn";
+ true ) | tar xf - --mode=+w --warning=no-timestamp
+ ;;
+ *.tar | *.tar.* | *.tgz | *.tbz2 | *.tbz)
+ tar xf "$fn" --mode=+w --warning=no-timestamp
+ ;;
+ *)
+ return 1
+ ;;
+ esac;
+ fi
+}
+_doStrip ()
+{
+
+ local -ra flags=(dontStripHost dontStripTarget);
+ local -ra debugDirs=(stripDebugList stripDebugListTarget);
+ local -ra allDirs=(stripAllList stripAllListTarget);
+ local -ra stripCmds=(STRIP STRIP_FOR_TARGET);
+ local -ra ranlibCmds=(RANLIB RANLIB_FOR_TARGET);
+ stripDebugList=${stripDebugList[*]:-lib lib32 lib64 libexec bin sbin Applications Library/Frameworks};
+ stripDebugListTarget=${stripDebugListTarget[*]:-};
+ stripAllList=${stripAllList[*]:-};
+ stripAllListTarget=${stripAllListTarget[*]:-};
+ local i;
+ for i in ${!stripCmds[@]};
+ do
+ local -n flag="${flags[$i]}";
+ local -n debugDirList="${debugDirs[$i]}";
+ local -n allDirList="${allDirs[$i]}";
+ local -n stripCmd="${stripCmds[$i]}";
+ local -n ranlibCmd="${ranlibCmds[$i]}";
+ if [[ -n "${dontStrip-}" || -n "${flag-}" ]] || ! type -f "${stripCmd-}" 2> /dev/null 1>&2; then
+ continue;
+ fi;
+ stripDirs "$stripCmd" "$ranlibCmd" "$debugDirList" "${stripDebugFlags[*]:--S -p}";
+ stripDirs "$stripCmd" "$ranlibCmd" "$allDirList" "${stripAllFlags[*]:--s -p}";
+ done
+}
+_eval ()
+{
+
+ if declare -F "$1" > /dev/null 2>&1; then
+ "$@";
+ else
+ eval "$1";
+ fi
+}
+_logHook ()
+{
+
+ if [[ -z ${NIX_LOG_FD-} ]]; then
+ return;
+ fi;
+ local hookKind="$1";
+ local hookExpr="$2";
+ shift 2;
+ if declare -F "$hookExpr" > /dev/null 2>&1; then
+ nixTalkativeLog "calling '$hookKind' function hook '$hookExpr'" "$@";
+ else
+ if type -p "$hookExpr" > /dev/null; then
+ nixTalkativeLog "sourcing '$hookKind' script hook '$hookExpr'";
+ else
+ if [[ "$hookExpr" != "_callImplicitHook"* ]]; then
+ local exprToOutput;
+ if [[ ${NIX_DEBUG:-0} -ge 5 ]]; then
+ exprToOutput="$hookExpr";
+ else
+ local hookExprLine;
+ while IFS= read -r hookExprLine; do
+ hookExprLine="${hookExprLine#"${hookExprLine%%[![:space:]]*}"}";
+ if [[ -n "$hookExprLine" ]]; then
+ exprToOutput+="$hookExprLine\\n ";
+ fi;
+ done <<< "$hookExpr";
+ exprToOutput="${exprToOutput%%\\n }";
+ fi;
+ nixTalkativeLog "evaling '$hookKind' string hook '$exprToOutput'";
+ fi;
+ fi;
+ fi
+}
+_makeSymlinksRelative ()
+{
+
+ local prefixes;
+ prefixes=();
+ for output in $(getAllOutputNames);
+ do
+ [ ! -e "${!output}" ] && continue;
+ prefixes+=("${!output}");
+ done;
+ find "${prefixes[@]}" -type l -printf '%H\0%p\0' | xargs -0 -n2 -r -P "$NIX_BUILD_CORES" sh -c '
+ output="$1"
+ link="$2"
+
+ linkTarget=$(readlink "$link")
+
+ # only touch links that point inside the same output tree
+ [[ $linkTarget == "$output"/* ]] || exit 0
+
+ if [ ! -e "$linkTarget" ]; then
+ echo "the symlink $link is broken, it points to $linkTarget (which is missing)"
+ fi
+
+ echo "making symlink relative: $link"
+ ln -snrf "$linkTarget" "$link"
+ ' _
+}
+_moveLib64 ()
+{
+
+ if [ "${dontMoveLib64-}" = 1 ]; then
+ return;
+ fi;
+ if [ ! -e "$prefix/lib64" -o -L "$prefix/lib64" ]; then
+ return;
+ fi;
+ echo "moving $prefix/lib64/* to $prefix/lib";
+ mkdir -p $prefix/lib;
+ shopt -s dotglob;
+ for i in $prefix/lib64/*;
+ do
+ mv --no-clobber "$i" $prefix/lib;
+ done;
+ shopt -u dotglob;
+ rmdir $prefix/lib64;
+ ln -s lib $prefix/lib64
+}
+_moveSbin ()
+{
+
+ if [ "${dontMoveSbin-}" = 1 ]; then
+ return;
+ fi;
+ if [ ! -e "$prefix/sbin" -o -L "$prefix/sbin" ]; then
+ return;
+ fi;
+ echo "moving $prefix/sbin/* to $prefix/bin";
+ mkdir -p $prefix/bin;
+ shopt -s dotglob;
+ for i in $prefix/sbin/*;
+ do
+ mv "$i" $prefix/bin;
+ done;
+ shopt -u dotglob;
+ rmdir $prefix/sbin;
+ ln -s bin $prefix/sbin
+}
+_moveSystemdUserUnits ()
+{
+
+ if [ "${dontMoveSystemdUserUnits:-0}" = 1 ]; then
+ return;
+ fi;
+ if [ ! -e "${prefix:?}/lib/systemd/user" ]; then
+ return;
+ fi;
+ local source="$prefix/lib/systemd/user";
+ local target="$prefix/share/systemd/user";
+ echo "moving $source/* to $target";
+ mkdir -p "$target";
+ ( shopt -s dotglob;
+ for i in "$source"/*;
+ do
+ mv "$i" "$target";
+ done );
+ rmdir "$source";
+ ln -s "$target" "$source"
+}
+_moveToShare ()
+{
+
+ if [ -n "$__structuredAttrs" ]; then
+ if [ -z "${forceShare-}" ]; then
+ forceShare=(man doc info);
+ fi;
+ else
+ forceShare=(${forceShare:-man doc info});
+ fi;
+ if [[ -z "$out" ]]; then
+ return;
+ fi;
+ for d in "${forceShare[@]}";
+ do
+ if [ -d "$out/$d" ]; then
+ if [ -d "$out/share/$d" ]; then
+ echo "both $d/ and share/$d/ exist!";
+ else
+ echo "moving $out/$d to $out/share/$d";
+ mkdir -p $out/share;
+ mv $out/$d $out/share/;
+ fi;
+ fi;
+ done
+}
+_multioutConfig ()
+{
+
+ if [ "$(getAllOutputNames)" = "out" ] || [ -z "${setOutputFlags-1}" ]; then
+ return;
+ fi;
+ if [ -z "${shareDocName:-}" ]; then
+ local confScript="${configureScript:-}";
+ if [ -z "$confScript" ] && [ -x ./configure ]; then
+ confScript=./configure;
+ fi;
+ if [ -f "$confScript" ]; then
+ local shareDocName="$(sed -n "s/^PACKAGE_TARNAME='\(.*\)'$/\1/p" < "$confScript")";
+ fi;
+ if [ -z "$shareDocName" ] || echo "$shareDocName" | grep -q '[^a-zA-Z0-9_-]'; then
+ shareDocName="$(echo "$name" | sed 's/-[^a-zA-Z].*//')";
+ fi;
+ fi;
+ prependToVar configureFlags --bindir="${!outputBin}"/bin --sbindir="${!outputBin}"/sbin --includedir="${!outputInclude}"/include --mandir="${!outputMan}"/share/man --infodir="${!outputInfo}"/share/info --docdir="${!outputDoc}"/share/doc/"${shareDocName}" --libdir="${!outputLib}"/lib --libexecdir="${!outputLib}"/libexec --localedir="${!outputLib}"/share/locale;
+ prependToVar installFlags pkgconfigdir="${!outputDev}"/lib/pkgconfig m4datadir="${!outputDev}"/share/aclocal aclocaldir="${!outputDev}"/share/aclocal
+}
+_multioutDevs ()
+{
+
+ if [ "$(getAllOutputNames)" = "out" ] || [ -z "${moveToDev-1}" ]; then
+ return;
+ fi;
+ moveToOutput include "${!outputInclude}";
+ moveToOutput lib/pkgconfig "${!outputDev}";
+ moveToOutput share/pkgconfig "${!outputDev}";
+ moveToOutput lib/cmake "${!outputDev}";
+ moveToOutput share/aclocal "${!outputDev}";
+ for f in "${!outputDev}"/{lib,share}/pkgconfig/*.pc;
+ do
+ echo "Patching '$f' includedir to output ${!outputInclude}";
+ sed -i "/^includedir=/s,=\${prefix},=${!outputInclude}," "$f";
+ done
+}
+_multioutDocs ()
+{
+
+ local REMOVE=REMOVE;
+ moveToOutput share/info "${!outputInfo}";
+ moveToOutput share/doc "${!outputDoc}";
+ moveToOutput share/gtk-doc "${!outputDevdoc}";
+ moveToOutput share/devhelp/books "${!outputDevdoc}";
+ moveToOutput share/man "${!outputMan}";
+ moveToOutput share/man/man3 "${!outputDevman}"
+}
+_multioutPropagateDev ()
+{
+
+ if [ "$(getAllOutputNames)" = "out" ]; then
+ return;
+ fi;
+ local outputFirst;
+ for outputFirst in $(getAllOutputNames);
+ do
+ break;
+ done;
+ local propagaterOutput="$outputDev";
+ if [ -z "$propagaterOutput" ]; then
+ propagaterOutput="$outputFirst";
+ fi;
+ if [ -z "${propagatedBuildOutputs+1}" ]; then
+ local po_dirty="$outputBin $outputInclude $outputLib";
+ set +o pipefail;
+ propagatedBuildOutputs=`echo "$po_dirty" | tr -s ' ' '\n' | grep -v -F "$propagaterOutput" | sort -u | tr '\n' ' ' `;
+ set -o pipefail;
+ fi;
+ if [ -z "$propagatedBuildOutputs" ]; then
+ return;
+ fi;
+ mkdir -p "${!propagaterOutput}"/nix-support;
+ for output in $propagatedBuildOutputs;
+ do
+ echo -n " ${!output}" >> "${!propagaterOutput}"/nix-support/propagated-build-inputs;
+ done
+}
+_nixLogWithLevel ()
+{
+
+ [[ -z ${NIX_LOG_FD-} || ${NIX_DEBUG:-0} -lt ${1:?} ]] && return 0;
+ local logLevel;
+ case "${1:?}" in
+ 0)
+ logLevel=ERROR
+ ;;
+ 1)
+ logLevel=WARN
+ ;;
+ 2)
+ logLevel=NOTICE
+ ;;
+ 3)
+ logLevel=INFO
+ ;;
+ 4)
+ logLevel=TALKATIVE
+ ;;
+ 5)
+ logLevel=CHATTY
+ ;;
+ 6)
+ logLevel=DEBUG
+ ;;
+ 7)
+ logLevel=VOMIT
+ ;;
+ *)
+ echo "_nixLogWithLevel: called with invalid log level: ${1:?}" >&"$NIX_LOG_FD";
+ return 1
+ ;;
+ esac;
+ local callerName="${FUNCNAME[2]}";
+ if [[ $callerName == "_callImplicitHook" ]]; then
+ callerName="${hookName:?}";
+ fi;
+ printf "%s: %s: %s\n" "$logLevel" "$callerName" "${2:?}" >&"$NIX_LOG_FD"
+}
+_overrideFirst ()
+{
+
+ if [ -z "${!1-}" ]; then
+ _assignFirst "$@";
+ fi
+}
+_pruneLibtoolFiles ()
+{
+
+ if [ "${dontPruneLibtoolFiles-}" ] || [ ! -e "$prefix" ]; then
+ return;
+ fi;
+ find "$prefix" -type f -name '*.la' -exec grep -q '^# Generated by .*libtool' {} \; -exec grep -q "^old_library=''" {} \; -exec sed -i {} -e "/^dependency_libs='[^']/ c dependency_libs='' #pruned" \;
+}
+_updateSourceDateEpochFromSourceRoot ()
+{
+
+ if [ -n "$sourceRoot" ]; then
+ updateSourceDateEpoch "$sourceRoot";
+ fi
+}
+activatePackage ()
+{
+
+ local pkg="$1";
+ local -r hostOffset="$2";
+ local -r targetOffset="$3";
+ (( hostOffset <= targetOffset )) || exit 1;
+ if [ -f "$pkg" ]; then
+ nixTalkativeLog "sourcing setup hook '$pkg'";
+ source "$pkg";
+ fi;
+ if [[ -z "${strictDeps-}" || "$hostOffset" -le -1 ]]; then
+ addToSearchPath _PATH "$pkg/bin";
+ fi;
+ if (( hostOffset <= -1 )); then
+ addToSearchPath _XDG_DATA_DIRS "$pkg/share";
+ fi;
+ if [[ "$hostOffset" -eq 0 && -d "$pkg/bin" ]]; then
+ addToSearchPath _HOST_PATH "$pkg/bin";
+ fi;
+ if [[ -f "$pkg/nix-support/setup-hook" ]]; then
+ nixTalkativeLog "sourcing setup hook '$pkg/nix-support/setup-hook'";
+ source "$pkg/nix-support/setup-hook";
+ fi
+}
+addCMakeParams ()
+{
+
+ addToSearchPath NIXPKGS_CMAKE_PREFIX_PATH $1
+}
+addEnvHooks ()
+{
+
+ local depHostOffset="$1";
+ shift;
+ local pkgHookVarsSlice="${pkgHookVarVars[$depHostOffset + 1]}[@]";
+ local pkgHookVar;
+ for pkgHookVar in "${!pkgHookVarsSlice}";
+ do
+ eval "${pkgHookVar}s"'+=("$@")';
+ done
+}
+addToSearchPath ()
+{
+
+ addToSearchPathWithCustomDelimiter ":" "$@"
+}
+addToSearchPathWithCustomDelimiter ()
+{
+
+ local delimiter="$1";
+ local varName="$2";
+ local dir="$3";
+ if [[ -d "$dir" && "${!varName:+${delimiter}${!varName}${delimiter}}" != *"${delimiter}${dir}${delimiter}"* ]]; then
+ export "${varName}=${!varName:+${!varName}${delimiter}}${dir}";
+ fi
+}
+appendToVar ()
+{
+
+ local -n nameref="$1";
+ local useArray type;
+ if [ -n "$__structuredAttrs" ]; then
+ useArray=true;
+ else
+ useArray=false;
+ fi;
+ if type=$(declare -p "$1" 2> /dev/null); then
+ case "${type#* }" in
+ -A*)
+ echo "appendToVar(): ERROR: trying to use appendToVar on an associative array, use variable+=([\"X\"]=\"Y\") instead." 1>&2;
+ return 1
+ ;;
+ -a*)
+ useArray=true
+ ;;
+ *)
+ useArray=false
+ ;;
+ esac;
+ fi;
+ shift;
+ if $useArray; then
+ nameref=(${nameref+"${nameref[@]}"} "$@");
+ else
+ nameref="${nameref-} $*";
+ fi
+}
+auditTmpdir ()
+{
+
+ local dir="$1";
+ [ -e "$dir" ] || return 0;
+ echo "checking for references to $TMPDIR/ in $dir...";
+ local tmpdir elf_fifo script_fifo;
+ tmpdir="$(mktemp -d)";
+ elf_fifo="$tmpdir/elf";
+ script_fifo="$tmpdir/script";
+ mkfifo "$elf_fifo" "$script_fifo";
+ ( find "$dir" -type f -not -path '*/.build-id/*' -print0 | while IFS= read -r -d '' file; do
+ if isELF "$file"; then
+ printf '%s\0' "$file" 1>&3;
+ else
+ if isScript "$file"; then
+ filename=${file##*/};
+ dir=${file%/*};
+ if [ -e "$dir/.$filename-wrapped" ]; then
+ printf '%s\0' "$file" 1>&4;
+ fi;
+ fi;
+ fi;
+ done;
+ exec 3>&- 4>&- ) 3> "$elf_fifo" 4> "$script_fifo" & ( xargs -0 -r -P "$NIX_BUILD_CORES" -n 1 sh -c '
+ if { printf :; patchelf --print-rpath "$1"; } | grep -q -F ":$TMPDIR/"; then
+ echo "RPATH of binary $1 contains a forbidden reference to $TMPDIR/"
+ exit 1
+ fi
+ ' _ < "$elf_fifo" ) & local pid_elf=$!;
+ local pid_script;
+ ( xargs -0 -r -P "$NIX_BUILD_CORES" -n 1 sh -c '
+ if grep -q -F "$TMPDIR/" "$1"; then
+ echo "wrapper script $1 contains a forbidden reference to $TMPDIR/"
+ exit 1
+ fi
+ ' _ < "$script_fifo" ) & local pid_script=$!;
+ wait "$pid_elf" || {
+ echo "Some binaries contain forbidden references to $TMPDIR/. Check the error above!";
+ exit 1
+ };
+ wait "$pid_script" || {
+ echo "Some scripts contain forbidden references to $TMPDIR/. Check the error above!";
+ exit 1
+ };
+ rm -r "$tmpdir"
+}
+bintoolsWrapper_addLDVars ()
+{
+
+ local role_post;
+ getHostRoleEnvHook;
+ if [[ -d "$1/lib64" && ! -L "$1/lib64" ]]; then
+ export NIX_LDFLAGS${role_post}+=" -L$1/lib64";
+ fi;
+ if [[ -d "$1/lib" ]]; then
+ local -a glob=($1/lib/lib*);
+ if [ "${#glob[*]}" -gt 0 ]; then
+ export NIX_LDFLAGS${role_post}+=" -L$1/lib";
+ fi;
+ fi
+}
+buildPhase ()
+{
+
+ runHook preBuild;
+ if [[ -z "${makeFlags-}" && -z "${makefile:-}" && ! ( -e Makefile || -e makefile || -e GNUmakefile ) ]]; then
+ echo "no Makefile or custom buildPhase, doing nothing";
+ else
+ foundMakefile=1;
+ local flagsArray=(${enableParallelBuilding:+-j${NIX_BUILD_CORES}} SHELL="$SHELL");
+ concatTo flagsArray makeFlags makeFlagsArray buildFlags buildFlagsArray;
+ echoCmd 'build flags' "${flagsArray[@]}";
+ make ${makefile:+-f $makefile} "${flagsArray[@]}";
+ unset flagsArray;
+ fi;
+ runHook postBuild
+}
+ccWrapper_addCVars ()
+{
+
+ local role_post;
+ getHostRoleEnvHook;
+ local found=;
+ if [ -d "$1/include" ]; then
+ export NIX_CFLAGS_COMPILE${role_post}+=" -isystem $1/include";
+ found=1;
+ fi;
+ if [ -d "$1/Library/Frameworks" ]; then
+ export NIX_CFLAGS_COMPILE${role_post}+=" -iframework $1/Library/Frameworks";
+ found=1;
+ fi;
+ if [[ -n "" && -n ${NIX_STORE:-} && -n $found ]]; then
+ local scrubbed="$NIX_STORE/eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee-${1#"$NIX_STORE"/*-}";
+ export NIX_CFLAGS_COMPILE${role_post}+=" -fmacro-prefix-map=$1=$scrubbed";
+ fi
+}
+checkPhase ()
+{
+
+ runHook preCheck;
+ if [[ -z "${foundMakefile:-}" ]]; then
+ echo "no Makefile or custom checkPhase, doing nothing";
+ runHook postCheck;
+ return;
+ fi;
+ if [[ -z "${checkTarget:-}" ]]; then
+ if make -n ${makefile:+-f $makefile} check > /dev/null 2>&1; then
+ checkTarget="check";
+ else
+ if make -n ${makefile:+-f $makefile} test > /dev/null 2>&1; then
+ checkTarget="test";
+ fi;
+ fi;
+ fi;
+ if [[ -z "${checkTarget:-}" ]]; then
+ echo "no check/test target in ${makefile:-Makefile}, doing nothing";
+ else
+ local flagsArray=(${enableParallelChecking:+-j${NIX_BUILD_CORES}} SHELL="$SHELL");
+ concatTo flagsArray makeFlags makeFlagsArray checkFlags=VERBOSE=y checkFlagsArray checkTarget;
+ echoCmd 'check flags' "${flagsArray[@]}";
+ make ${makefile:+-f $makefile} "${flagsArray[@]}";
+ unset flagsArray;
+ fi;
+ runHook postCheck
+}
+cmakeConfigurePhase ()
+{
+
+ runHook preConfigure;
+ : ${cmakeBuildDir:=build};
+ export CTEST_OUTPUT_ON_FAILURE=1;
+ if [ -n "${enableParallelChecking-1}" ]; then
+ export CTEST_PARALLEL_LEVEL=$NIX_BUILD_CORES;
+ fi;
+ if [ -z "${dontUseCmakeBuildDir-}" ]; then
+ mkdir -p "$cmakeBuildDir";
+ cd "$cmakeBuildDir";
+ : ${cmakeDir:=..};
+ else
+ : ${cmakeDir:=.};
+ fi;
+ if [ -z "${dontAddPrefix-}" ]; then
+ prependToVar cmakeFlags "-DCMAKE_INSTALL_PREFIX=$prefix";
+ fi;
+ prependToVar cmakeFlags "-DCMAKE_CXX_COMPILER=$CXX";
+ prependToVar cmakeFlags "-DCMAKE_C_COMPILER=$CC";
+ prependToVar cmakeFlags "-DCMAKE_AR=$(command -v $AR)";
+ prependToVar cmakeFlags "-DCMAKE_RANLIB=$(command -v $RANLIB)";
+ prependToVar cmakeFlags "-DCMAKE_STRIP=$(command -v $STRIP)";
+ prependToVar cmakeFlags "-DCMAKE_INSTALL_NAME_DIR=${!outputLib}/lib";
+ if [[ -z "$shareDocName" ]]; then
+ local cmakeLists="${cmakeDir}/CMakeLists.txt";
+ if [[ -f "$cmakeLists" ]]; then
+ local shareDocName="$(grep --only-matching --perl-regexp --ignore-case '\bproject\s*\(\s*"?\K([^[:space:]")]+)' < "$cmakeLists" | head -n1)";
+ fi;
+ if [[ -z "$shareDocName" ]] || echo "$shareDocName" | grep -q '[^a-zA-Z0-9_+-]'; then
+ if [[ -n "${pname-}" ]]; then
+ shareDocName="$pname";
+ else
+ shareDocName="$(echo "$name" | sed 's/-[^a-zA-Z].*//')";
+ fi;
+ fi;
+ fi;
+ prependToVar cmakeFlags "-DCMAKE_INSTALL_BINDIR=${!outputBin}/bin";
+ prependToVar cmakeFlags "-DCMAKE_INSTALL_SBINDIR=${!outputBin}/sbin";
+ prependToVar cmakeFlags "-DCMAKE_INSTALL_INCLUDEDIR=${!outputInclude}/include";
+ prependToVar cmakeFlags "-DCMAKE_INSTALL_MANDIR=${!outputMan}/share/man";
+ prependToVar cmakeFlags "-DCMAKE_INSTALL_INFODIR=${!outputInfo}/share/info";
+ prependToVar cmakeFlags "-DCMAKE_INSTALL_DOCDIR=${!outputDoc}/share/doc/${shareDocName}";
+ prependToVar cmakeFlags "-DCMAKE_INSTALL_LIBDIR=${!outputLib}/lib";
+ prependToVar cmakeFlags "-DCMAKE_INSTALL_LIBEXECDIR=${!outputLib}/libexec";
+ prependToVar cmakeFlags "-DCMAKE_INSTALL_LOCALEDIR=${!outputLib}/share/locale";
+ if [ -z "${doCheck-}" ]; then
+ prependToVar cmakeFlags "-DBUILD_TESTING=OFF";
+ fi;
+ prependToVar cmakeFlags "-DCMAKE_BUILD_TYPE=${cmakeBuildType:-Release}";
+ prependToVar cmakeFlags "-DCMAKE_EXPORT_NO_PACKAGE_REGISTRY=ON";
+ prependToVar cmakeFlags "-DCMAKE_FIND_USE_PACKAGE_REGISTRY=OFF";
+ prependToVar cmakeFlags "-DCMAKE_FIND_USE_SYSTEM_PACKAGE_REGISTRY=OFF";
+ if [ "${buildPhase-}" = ninjaBuildPhase ]; then
+ prependToVar cmakeFlags "-GNinja";
+ fi;
+ local flagsArray=();
+ concatTo flagsArray cmakeFlags cmakeFlagsArray;
+ echoCmd 'cmake flags' "${flagsArray[@]}";
+ cmake "$cmakeDir" "${flagsArray[@]}";
+ if ! [[ -v enableParallelBuilding ]]; then
+ enableParallelBuilding=1;
+ echo "cmake: enabled parallel building";
+ fi;
+ if [[ "$enableParallelBuilding" -ne 0 ]]; then
+ export CMAKE_BUILD_PARALLEL_LEVEL=$NIX_BUILD_CORES;
+ fi;
+ if ! [[ -v enableParallelInstalling ]]; then
+ enableParallelInstalling=1;
+ echo "cmake: enabled parallel installing";
+ fi;
+ runHook postConfigure
+}
+cmakePcfileCheckPhase ()
+{
+
+ while IFS= read -rd '' file; do
+ grepout=$(grep --line-number '}//nix/store' "$file" || true);
+ if [ -n "$grepout" ]; then
+ {
+ echo "Broken paths found in a .pc file! $file";
+ echo "The following lines have issues (specifically '//' in paths).";
+ echo "$grepout";
+ echo "It is very likely that paths are being joined improperly.";
+ echo 'ex: "${prefix}/@CMAKE_INSTALL_LIBDIR@" should be "@CMAKE_INSTALL_FULL_LIBDIR@"';
+ echo "Please see https://github.com/NixOS/nixpkgs/issues/144170 for more details.";
+ exit 1
+ } 1>&2;
+ fi;
+ done < <(find "${!outputDev}" -iname "*.pc" -print0)
+}
+compressManPages ()
+{
+
+ local dir="$1";
+ if [ -L "$dir"/share ] || [ -L "$dir"/share/man ] || [ ! -d "$dir/share/man" ]; then
+ return;
+ fi;
+ echo "gzipping man pages under $dir/share/man/";
+ find "$dir"/share/man/ -type f -a '!' -regex '.*\.\(bz2\|gz\|xz\)$' -print0 | xargs -0 -n1 -P "$NIX_BUILD_CORES" gzip -n -f;
+ find "$dir"/share/man/ -type l -a '!' -regex '.*\.\(bz2\|gz\|xz\)$' -print0 | sort -z | while IFS= read -r -d '' f; do
+ local target;
+ target="$(readlink -f "$f")";
+ if [ -f "$target".gz ]; then
+ ln -sf "$target".gz "$f".gz && rm "$f";
+ fi;
+ done
+}
+concatStringsSep ()
+{
+
+ local sep="$1";
+ local name="$2";
+ local type oldifs;
+ if type=$(declare -p "$name" 2> /dev/null); then
+ local -n nameref="$name";
+ case "${type#* }" in
+ -A*)
+ echo "concatStringsSep(): ERROR: trying to use concatStringsSep on an associative array." 1>&2;
+ return 1
+ ;;
+ -a*)
+ local IFS="$(printf '\036')"
+ ;;
+ *)
+ local IFS=" "
+ ;;
+ esac;
+ local ifs_separated="${nameref[*]}";
+ echo -n "${ifs_separated//"$IFS"/"$sep"}";
+ fi
+}
+concatTo ()
+{
+
+ local -;
+ set -o noglob;
+ local -n targetref="$1";
+ shift;
+ local arg default name type;
+ for arg in "$@";
+ do
+ IFS="=" read -r name default <<< "$arg";
+ local -n nameref="$name";
+ if [[ -z "${nameref[*]}" && -n "$default" ]]; then
+ targetref+=("$default");
+ else
+ if type=$(declare -p "$name" 2> /dev/null); then
+ case "${type#* }" in
+ -A*)
+ echo "concatTo(): ERROR: trying to use concatTo on an associative array." 1>&2;
+ return 1
+ ;;
+ -a*)
+ targetref+=("${nameref[@]}")
+ ;;
+ *)
+ if [[ "$name" = *"Array" ]]; then
+ nixErrorLog "concatTo(): $name is not declared as array, treating as a singleton. This will become an error in future";
+ targetref+=(${nameref+"${nameref[@]}"});
+ else
+ targetref+=(${nameref-});
+ fi
+ ;;
+ esac;
+ fi;
+ fi;
+ done
+}
+configurePhase ()
+{
+
+ runHook preConfigure;
+ : "${configureScript=}";
+ if [[ -z "$configureScript" && -x ./configure ]]; then
+ configureScript=./configure;
+ fi;
+ if [ -z "${dontFixLibtool:-}" ]; then
+ export lt_cv_deplibs_check_method="${lt_cv_deplibs_check_method-pass_all}";
+ local i;
+ find . -iname "ltmain.sh" -print0 | while IFS='' read -r -d '' i; do
+ echo "fixing libtool script $i";
+ fixLibtool "$i";
+ done;
+ CONFIGURE_MTIME_REFERENCE=$(mktemp configure.mtime.reference.XXXXXX);
+ find . -executable -type f -name configure -exec grep -l 'GNU Libtool is free software; you can redistribute it and/or modify' {} \; -exec touch -r {} "$CONFIGURE_MTIME_REFERENCE" \; -exec sed -i s_/usr/bin/file_file_g {} \; -exec touch -r "$CONFIGURE_MTIME_REFERENCE" {} \;;
+ rm -f "$CONFIGURE_MTIME_REFERENCE";
+ fi;
+ if [[ -z "${dontAddPrefix:-}" && -n "$prefix" ]]; then
+ local -r prefixKeyOrDefault="${prefixKey:---prefix=}";
+ if [ "${prefixKeyOrDefault: -1}" = " " ]; then
+ prependToVar configureFlags "$prefix";
+ prependToVar configureFlags "${prefixKeyOrDefault::-1}";
+ else
+ prependToVar configureFlags "$prefixKeyOrDefault$prefix";
+ fi;
+ fi;
+ if [[ -f "$configureScript" ]]; then
+ if [ -z "${dontAddDisableDepTrack:-}" ]; then
+ if grep -q dependency-tracking "$configureScript"; then
+ prependToVar configureFlags --disable-dependency-tracking;
+ fi;
+ fi;
+ if [ -z "${dontDisableStatic:-}" ]; then
+ if grep -q enable-static "$configureScript"; then
+ prependToVar configureFlags --disable-static;
+ fi;
+ fi;
+ if [ -z "${dontPatchShebangsInConfigure:-}" ]; then
+ patchShebangs --build "$configureScript";
+ fi;
+ fi;
+ if [ -n "$configureScript" ]; then
+ local -a flagsArray;
+ concatTo flagsArray configureFlags configureFlagsArray;
+ echoCmd 'configure flags' "${flagsArray[@]}";
+ $configureScript "${flagsArray[@]}";
+ unset flagsArray;
+ else
+ echo "no configure script, doing nothing";
+ fi;
+ runHook postConfigure
+}
+consumeEntire ()
+{
+
+ if IFS='' read -r -d '' "$1"; then
+ echo "consumeEntire(): ERROR: Input null bytes, won't process" 1>&2;
+ return 1;
+ fi
+}
+definePhases ()
+{
+
+ if [ -z "${phases[*]:-}" ]; then
+ phases="${prePhases[*]:-} unpackPhase patchPhase ${preConfigurePhases[*]:-} configurePhase ${preBuildPhases[*]:-} buildPhase checkPhase ${preInstallPhases[*]:-} installPhase ${preFixupPhases[*]:-} fixupPhase installCheckPhase ${preDistPhases[*]:-} distPhase ${postPhases[*]:-}";
+ fi
+}
+distPhase ()
+{
+
+ runHook preDist;
+ local flagsArray=();
+ concatTo flagsArray distFlags distFlagsArray distTarget=dist;
+ echo 'dist flags: %q' "${flagsArray[@]}";
+ make ${makefile:+-f $makefile} "${flagsArray[@]}";
+ if [ "${dontCopyDist:-0}" != 1 ]; then
+ mkdir -p "$out/tarballs";
+ cp -pvd ${tarballs[*]:-*.tar.gz} "$out/tarballs";
+ fi;
+ runHook postDist
+}
+dumpVars ()
+{
+
+ if [[ "${noDumpEnvVars:-0}" != 1 && -d "$NIX_BUILD_TOP" ]]; then
+ local old_umask;
+ old_umask=$(umask);
+ umask 0077;
+ export 2> /dev/null > "$NIX_BUILD_TOP/env-vars";
+ umask "$old_umask";
+ fi
+}
+echoCmd ()
+{
+
+ printf "%s:" "$1";
+ shift;
+ printf ' %q' "$@";
+ echo
+}
+exitHandler ()
+{
+
+ exitCode="$?";
+ set +e;
+ if [ -n "${showBuildStats:-}" ]; then
+ read -r -d '' -a buildTimes < <(times);
+ echo "build times:";
+ echo "user time for the shell ${buildTimes[0]}";
+ echo "system time for the shell ${buildTimes[1]}";
+ echo "user time for all child processes ${buildTimes[2]}";
+ echo "system time for all child processes ${buildTimes[3]}";
+ fi;
+ if (( "$exitCode" != 0 )); then
+ runHook failureHook;
+ if [ -n "${succeedOnFailure:-}" ]; then
+ echo "build failed with exit code $exitCode (ignored)";
+ mkdir -p "$out/nix-support";
+ printf "%s" "$exitCode" > "$out/nix-support/failed";
+ exit 0;
+ fi;
+ else
+ runHook exitHook;
+ fi;
+ return "$exitCode"
+}
+findInputs ()
+{
+
+ local -r pkg="$1";
+ local -r hostOffset="$2";
+ local -r targetOffset="$3";
+ (( hostOffset <= targetOffset )) || exit 1;
+ local varVar="${pkgAccumVarVars[hostOffset + 1]}";
+ local varRef="$varVar[$((targetOffset - hostOffset))]";
+ local var="${!varRef}";
+ unset -v varVar varRef;
+ local varSlice="$var[*]";
+ case " ${!varSlice-} " in
+ *" $pkg "*)
+ return 0
+ ;;
+ esac;
+ unset -v varSlice;
+ eval "$var"'+=("$pkg")';
+ if ! [ -e "$pkg" ]; then
+ echo "build input $pkg does not exist" 1>&2;
+ exit 1;
+ fi;
+ function mapOffset ()
+ {
+ local -r inputOffset="$1";
+ local -n outputOffset="$2";
+ if (( inputOffset <= 0 )); then
+ outputOffset=$((inputOffset + hostOffset));
+ else
+ outputOffset=$((inputOffset - 1 + targetOffset));
+ fi
+ };
+ local relHostOffset;
+ for relHostOffset in "${allPlatOffsets[@]}";
+ do
+ local files="${propagatedDepFilesVars[relHostOffset + 1]}";
+ local hostOffsetNext;
+ mapOffset "$relHostOffset" hostOffsetNext;
+ (( -1 <= hostOffsetNext && hostOffsetNext <= 1 )) || continue;
+ local relTargetOffset;
+ for relTargetOffset in "${allPlatOffsets[@]}";
+ do
+ (( "$relHostOffset" <= "$relTargetOffset" )) || continue;
+ local fileRef="${files}[$relTargetOffset - $relHostOffset]";
+ local file="${!fileRef}";
+ unset -v fileRef;
+ local targetOffsetNext;
+ mapOffset "$relTargetOffset" targetOffsetNext;
+ (( -1 <= hostOffsetNext && hostOffsetNext <= 1 )) || continue;
+ [[ -f "$pkg/nix-support/$file" ]] || continue;
+ local pkgNext;
+ read -r -d '' pkgNext < "$pkg/nix-support/$file" || true;
+ for pkgNext in $pkgNext;
+ do
+ findInputs "$pkgNext" "$hostOffsetNext" "$targetOffsetNext";
+ done;
+ done;
+ done
+}
+fixLibtool ()
+{
+
+ local search_path;
+ for flag in $NIX_LDFLAGS;
+ do
+ case $flag in
+ -L*)
+ search_path+=" ${flag#-L}"
+ ;;
+ esac;
+ done;
+ sed -i "$1" -e "s^eval \(sys_lib_search_path=\).*^\1'${search_path:-}'^" -e 's^eval sys_lib_.+search_path=.*^^'
+}
+fixupPhase ()
+{
+
+ local output;
+ for output in $(getAllOutputNames);
+ do
+ if [ -e "${!output}" ]; then
+ chmod -R u+w,u-s,g-s "${!output}";
+ fi;
+ done;
+ runHook preFixup;
+ local output;
+ for output in $(getAllOutputNames);
+ do
+ prefix="${!output}" runHook fixupOutput;
+ done;
+ recordPropagatedDependencies;
+ if [ -n "${setupHook:-}" ]; then
+ mkdir -p "${!outputDev}/nix-support";
+ substituteAll "$setupHook" "${!outputDev}/nix-support/setup-hook";
+ fi;
+ if [ -n "${setupHooks:-}" ]; then
+ mkdir -p "${!outputDev}/nix-support";
+ local hook;
+ for hook in ${setupHooks[@]};
+ do
+ local content;
+ consumeEntire content < "$hook";
+ substituteAllStream content "file '$hook'" >> "${!outputDev}/nix-support/setup-hook";
+ unset -v content;
+ done;
+ unset -v hook;
+ fi;
+ if [ -n "${propagatedUserEnvPkgs[*]:-}" ]; then
+ mkdir -p "${!outputBin}/nix-support";
+ printWords "${propagatedUserEnvPkgs[@]}" > "${!outputBin}/nix-support/propagated-user-env-packages";
+ fi;
+ runHook postFixup
+}
+genericBuild ()
+{
+
+ export GZIP_NO_TIMESTAMPS=1;
+ if [ -f "${buildCommandPath:-}" ]; then
+ source "$buildCommandPath";
+ return;
+ fi;
+ if [ -n "${buildCommand:-}" ]; then
+ eval "$buildCommand";
+ return;
+ fi;
+ definePhases;
+ for curPhase in ${phases[*]};
+ do
+ runPhase "$curPhase";
+ done
+}
+getAllOutputNames ()
+{
+
+ if [ -n "$__structuredAttrs" ]; then
+ echo "${!outputs[*]}";
+ else
+ echo "$outputs";
+ fi
+}
+getHostRole ()
+{
+
+ getRole "$hostOffset"
+}
+getHostRoleEnvHook ()
+{
+
+ getRole "$depHostOffset"
+}
+getRole ()
+{
+
+ case $1 in
+ -1)
+ role_post='_FOR_BUILD'
+ ;;
+ 0)
+ role_post=''
+ ;;
+ 1)
+ role_post='_FOR_TARGET'
+ ;;
+ *)
+ echo "binutils-wrapper-2.46: used as improper sort of dependency" 1>&2;
+ return 1
+ ;;
+ esac
+}
+getTargetRole ()
+{
+
+ getRole "$targetOffset"
+}
+getTargetRoleEnvHook ()
+{
+
+ getRole "$depTargetOffset"
+}
+getTargetRoleWrapper ()
+{
+
+ case $targetOffset in
+ -1)
+ export NIX_BINTOOLS_WRAPPER_TARGET_BUILD_x86_64_unknown_linux_gnu=1
+ ;;
+ 0)
+ export NIX_BINTOOLS_WRAPPER_TARGET_HOST_x86_64_unknown_linux_gnu=1
+ ;;
+ 1)
+ export NIX_BINTOOLS_WRAPPER_TARGET_TARGET_x86_64_unknown_linux_gnu=1
+ ;;
+ *)
+ echo "binutils-wrapper-2.46: used as improper sort of dependency" 1>&2;
+ return 1
+ ;;
+ esac
+}
+installCheckPhase ()
+{
+
+ runHook preInstallCheck;
+ if [[ -z "${foundMakefile:-}" ]]; then
+ echo "no Makefile or custom installCheckPhase, doing nothing";
+ else
+ if [[ -z "${installCheckTarget:-}" ]] && ! make -n ${makefile:+-f $makefile} "${installCheckTarget:-installcheck}" > /dev/null 2>&1; then
+ echo "no installcheck target in ${makefile:-Makefile}, doing nothing";
+ else
+ local flagsArray=(${enableParallelChecking:+-j${NIX_BUILD_CORES}} SHELL="$SHELL");
+ concatTo flagsArray makeFlags makeFlagsArray installCheckFlags installCheckFlagsArray installCheckTarget=installcheck;
+ echoCmd 'installcheck flags' "${flagsArray[@]}";
+ make ${makefile:+-f $makefile} "${flagsArray[@]}";
+ unset flagsArray;
+ fi;
+ fi;
+ runHook postInstallCheck
+}
+installPhase ()
+{
+
+ runHook preInstall;
+ if [[ -z "${makeFlags-}" && -z "${makefile:-}" && ! ( -e Makefile || -e makefile || -e GNUmakefile ) ]]; then
+ echo "no Makefile or custom installPhase, doing nothing";
+ runHook postInstall;
+ return;
+ else
+ foundMakefile=1;
+ fi;
+ if [ -n "$prefix" ]; then
+ mkdir -p "$prefix";
+ fi;
+ local flagsArray=(${enableParallelInstalling:+-j${NIX_BUILD_CORES}} SHELL="$SHELL");
+ concatTo flagsArray makeFlags makeFlagsArray installFlags installFlagsArray installTargets=install;
+ echoCmd 'install flags' "${flagsArray[@]}";
+ make ${makefile:+-f $makefile} "${flagsArray[@]}";
+ unset flagsArray;
+ runHook postInstall
+}
+isELF ()
+{
+
+ local fn="$1";
+ local fd;
+ local magic;
+ exec {fd}< "$fn";
+ LANG=C read -r -n 4 -u "$fd" magic;
+ exec {fd}>&-;
+ if [ "$magic" = 'ELF' ]; then
+ return 0;
+ else
+ return 1;
+ fi
+}
+isMachO ()
+{
+
+ local fn="$1";
+ local fd;
+ local magic;
+ exec {fd}< "$fn";
+ LANG=C read -r -n 4 -u "$fd" magic;
+ exec {fd}>&-;
+ if [[ "$magic" = $(echo -ne "\xfe\xed\xfa\xcf") || "$magic" = $(echo -ne "\xcf\xfa\xed\xfe") ]]; then
+ return 0;
+ else
+ if [[ "$magic" = $(echo -ne "\xfe\xed\xfa\xce") || "$magic" = $(echo -ne "\xce\xfa\xed\xfe") ]]; then
+ return 0;
+ else
+ if [[ "$magic" = $(echo -ne "\xca\xfe\xba\xbe") || "$magic" = $(echo -ne "\xbe\xba\xfe\xca") ]]; then
+ return 0;
+ else
+ return 1;
+ fi;
+ fi;
+ fi
+}
+isScript ()
+{
+
+ local fn="$1";
+ local fd;
+ local magic;
+ exec {fd}< "$fn";
+ LANG=C read -r -n 2 -u "$fd" magic;
+ exec {fd}>&-;
+ if [[ "$magic" =~ \#! ]]; then
+ return 0;
+ else
+ return 1;
+ fi
+}
+justBuildPhase ()
+{
+
+ runHook preBuild;
+ local flagsArray=();
+ concatTo flagsArray justFlags justFlagsArray;
+ echoCmd 'build flags' "${flagsArray[@]}";
+ just "${flagsArray[@]}";
+ runHook postBuild
+}
+justCheckPhase ()
+{
+
+ runHook preCheck;
+ if [ -z "${checkTarget:-}" ]; then
+ if just -n test > /dev/null 2>&1; then
+ checkTarget="test";
+ fi;
+ fi;
+ if [ -z "${checkTarget:-}" ]; then
+ echo "no test target found in just, doing nothing";
+ else
+ local flagsArray=();
+ concatTo flagsArray justFlags justFlagsArray checkTarget;
+ echoCmd 'check flags' "${flagsArray[@]}";
+ just "${flagsArray[@]}";
+ fi;
+ runHook postCheck
+}
+justInstallPhase ()
+{
+
+ runHook preInstall;
+ local flagsArray=();
+ concatTo flagsArray justFlags justFlagsArray installTargets=install;
+ echoCmd 'install flags' "${flagsArray[@]}";
+ just "${flagsArray[@]}";
+ runHook postInstall
+}
+makeCmakeFindLibs ()
+{
+
+ isystem_seen=;
+ iframework_seen=;
+ for flag in ${NIX_CFLAGS_COMPILE-} ${NIX_LDFLAGS-};
+ do
+ if test -n "$isystem_seen" && test -d "$flag"; then
+ isystem_seen=;
+ addToSearchPath CMAKE_INCLUDE_PATH "${flag}";
+ else
+ if test -n "$iframework_seen" && test -d "$flag"; then
+ iframework_seen=;
+ addToSearchPath CMAKE_FRAMEWORK_PATH "${flag}";
+ else
+ isystem_seen=;
+ iframework_seen=;
+ case $flag in
+ -I*)
+ addToSearchPath CMAKE_INCLUDE_PATH "${flag:2}"
+ ;;
+ -L*)
+ addToSearchPath CMAKE_LIBRARY_PATH "${flag:2}"
+ ;;
+ -F*)
+ addToSearchPath CMAKE_FRAMEWORK_PATH "${flag:2}"
+ ;;
+ -isystem)
+ isystem_seen=1
+ ;;
+ -iframework)
+ iframework_seen=1
+ ;;
+ esac;
+ fi;
+ fi;
+ done
+}
+mapOffset ()
+{
+
+ local -r inputOffset="$1";
+ local -n outputOffset="$2";
+ if (( inputOffset <= 0 )); then
+ outputOffset=$((inputOffset + hostOffset));
+ else
+ outputOffset=$((inputOffset - 1 + targetOffset));
+ fi
+}
+moveToOutput ()
+{
+
+ local patt="$1";
+ local dstOut="$2";
+ local output;
+ for output in $(getAllOutputNames);
+ do
+ if [ "${!output}" = "$dstOut" ]; then
+ continue;
+ fi;
+ local srcPath;
+ for srcPath in "${!output}"/$patt;
+ do
+ if [ ! -e "$srcPath" ] && [ ! -L "$srcPath" ]; then
+ continue;
+ fi;
+ if [ "$dstOut" = REMOVE ]; then
+ echo "Removing $srcPath";
+ rm -r "$srcPath";
+ else
+ local dstPath="$dstOut${srcPath#${!output}}";
+ echo "Moving $srcPath to $dstPath";
+ if [ -d "$dstPath" ] && [ -d "$srcPath" ]; then
+ rmdir "$srcPath" --ignore-fail-on-non-empty;
+ if [ -d "$srcPath" ]; then
+ mv -t "$dstPath" "$srcPath"/*;
+ rmdir "$srcPath";
+ fi;
+ else
+ mkdir -p "$(readlink -m "$dstPath/..")";
+ mv "$srcPath" "$dstPath";
+ fi;
+ fi;
+ local srcParent="$(readlink -m "$srcPath/..")";
+ if [ -n "$(find "$srcParent" -maxdepth 0 -type d -empty 2> /dev/null)" ]; then
+ echo "Removing empty $srcParent/ and (possibly) its parents";
+ rmdir -p --ignore-fail-on-non-empty "$srcParent" 2> /dev/null || true;
+ fi;
+ done;
+ done
+}
+nixChattyLog ()
+{
+
+ _nixLogWithLevel 5 "$*"
+}
+nixDebugLog ()
+{
+
+ _nixLogWithLevel 6 "$*"
+}
+nixErrorLog ()
+{
+
+ _nixLogWithLevel 0 "$*"
+}
+nixInfoLog ()
+{
+
+ _nixLogWithLevel 3 "$*"
+}
+nixLog ()
+{
+
+ [[ -z ${NIX_LOG_FD-} ]] && return 0;
+ local callerName="${FUNCNAME[1]}";
+ if [[ $callerName == "_callImplicitHook" ]]; then
+ callerName="${hookName:?}";
+ fi;
+ printf "%s: %s\n" "$callerName" "$*" >&"$NIX_LOG_FD"
+}
+nixNoticeLog ()
+{
+
+ _nixLogWithLevel 2 "$*"
+}
+nixTalkativeLog ()
+{
+
+ _nixLogWithLevel 4 "$*"
+}
+nixVomitLog ()
+{
+
+ _nixLogWithLevel 7 "$*"
+}
+nixWarnLog ()
+{
+
+ _nixLogWithLevel 1 "$*"
+}
+noBrokenSymlinks ()
+{
+
+ local -r output="${1:?}";
+ local path;
+ local pathParent;
+ local symlinkTarget;
+ local -i numDanglingSymlinks=0;
+ local -i numReflexiveSymlinks=0;
+ local -i numUnreadableSymlinks=0;
+ if [[ ! -e $output ]]; then
+ nixWarnLog "skipping non-existent output $output";
+ return 0;
+ fi;
+ nixInfoLog "running on $output";
+ while IFS= read -r -d '' path; do
+ pathParent="$(dirname "$path")";
+ if ! symlinkTarget="$(readlink "$path")"; then
+ nixErrorLog "the symlink $path is unreadable";
+ numUnreadableSymlinks+=1;
+ continue;
+ fi;
+ if [[ $symlinkTarget == /* ]]; then
+ nixInfoLog "symlink $path points to absolute target $symlinkTarget";
+ else
+ nixInfoLog "symlink $path points to relative target $symlinkTarget";
+ symlinkTarget="$(realpath --no-symlinks --canonicalize-missing "$pathParent/$symlinkTarget")";
+ fi;
+ if [[ $symlinkTarget = "$TMPDIR"/* ]]; then
+ nixErrorLog "the symlink $path points to $TMPDIR directory: $symlinkTarget";
+ numDanglingSymlinks+=1;
+ continue;
+ fi;
+ if [[ $symlinkTarget != "$NIX_STORE"/* ]]; then
+ nixInfoLog "symlink $path points outside the Nix store; ignoring";
+ continue;
+ fi;
+ if [[ $path == "$symlinkTarget" ]]; then
+ nixErrorLog "the symlink $path is reflexive";
+ numReflexiveSymlinks+=1;
+ else
+ if [[ ! -e $symlinkTarget ]]; then
+ nixErrorLog "the symlink $path points to a missing target: $symlinkTarget";
+ numDanglingSymlinks+=1;
+ else
+ nixDebugLog "the symlink $path is irreflexive and points to a target which exists";
+ fi;
+ fi;
+ done < <(find "$output" -type l -print0);
+ if ((numDanglingSymlinks > 0 || numReflexiveSymlinks > 0 || numUnreadableSymlinks > 0)); then
+ nixErrorLog "found $numDanglingSymlinks dangling symlinks, $numReflexiveSymlinks reflexive symlinks and $numUnreadableSymlinks unreadable symlinks";
+ exit 1;
+ fi;
+ return 0
+}
+noBrokenSymlinksInAllOutputs ()
+{
+
+ if [[ -z ${dontCheckForBrokenSymlinks-} ]]; then
+ for output in $(getAllOutputNames);
+ do
+ noBrokenSymlinks "${!output}";
+ done;
+ fi
+}
+patchELF ()
+{
+
+ local dir="$1";
+ [ -e "$dir" ] || return 0;
+ echo "shrinking RPATHs of ELF executables and libraries in $dir";
+ local i;
+ while IFS= read -r -d '' i; do
+ if [[ "$i" =~ .build-id ]]; then
+ continue;
+ fi;
+ if ! isELF "$i"; then
+ continue;
+ fi;
+ echo "shrinking $i";
+ patchelf --shrink-rpath "$i" || true;
+ done < <(find "$dir" -type f -print0)
+}
+patchPhase ()
+{
+
+ runHook prePatch;
+ local -a patchesArray;
+ concatTo patchesArray patches;
+ local -a flagsArray;
+ concatTo flagsArray patchFlags=-p1;
+ for i in "${patchesArray[@]}";
+ do
+ echo "applying patch $i";
+ local uncompress=cat;
+ case "$i" in
+ *.gz)
+ uncompress="gzip -d"
+ ;;
+ *.bz2)
+ uncompress="bzip2 -d"
+ ;;
+ *.xz)
+ uncompress="xz -d"
+ ;;
+ *.lzma)
+ uncompress="lzma -d"
+ ;;
+ esac;
+ $uncompress < "$i" 2>&1 | patch "${flagsArray[@]}";
+ done;
+ runHook postPatch
+}
+patchShebangs ()
+{
+
+ local pathName;
+ local update=false;
+ while [[ $# -gt 0 ]]; do
+ case "$1" in
+ --host)
+ pathName=HOST_PATH;
+ shift
+ ;;
+ --build)
+ pathName=PATH;
+ shift
+ ;;
+ --update)
+ update=true;
+ shift
+ ;;
+ --)
+ shift;
+ break
+ ;;
+ -* | --*)
+ echo "Unknown option $1 supplied to patchShebangs" 1>&2;
+ return 1
+ ;;
+ *)
+ break
+ ;;
+ esac;
+ done;
+ echo "patching script interpreter paths in $@";
+ local f;
+ local oldPath;
+ local newPath;
+ local arg0;
+ local args;
+ local oldInterpreterLine;
+ local newInterpreterLine;
+ if [[ $# -eq 0 ]]; then
+ echo "No arguments supplied to patchShebangs" 1>&2;
+ return 0;
+ fi;
+ local f;
+ while IFS= read -r -d '' f; do
+ isScript "$f" || continue;
+ read -r oldInterpreterLine < "$f" || [ "$oldInterpreterLine" ];
+ read -r oldPath arg0 args <<< "${oldInterpreterLine:2}";
+ if [[ -z "${pathName:-}" ]]; then
+ if [[ -n $strictDeps && $f == "$NIX_STORE"* ]]; then
+ pathName=HOST_PATH;
+ else
+ pathName=PATH;
+ fi;
+ fi;
+ if [[ "$oldPath" == *"/bin/env" ]]; then
+ if [[ $arg0 == "-S" ]]; then
+ arg0=${args%% *};
+ [[ "$args" == *" "* ]] && args=${args#* } || args=;
+ newPath="$(PATH="${!pathName}" type -P "env" || true)";
+ args="-S $(PATH="${!pathName}" type -P "$arg0" || true) $args";
+ else
+ if [[ $arg0 == "-"* || $arg0 == *"="* ]]; then
+ echo "$f: unsupported interpreter directive \"$oldInterpreterLine\" (set dontPatchShebangs=1 and handle shebang patching yourself)" 1>&2;
+ exit 1;
+ else
+ newPath="$(PATH="${!pathName}" type -P "$arg0" || true)";
+ fi;
+ fi;
+ else
+ if [[ -z $oldPath ]]; then
+ oldPath="/bin/sh";
+ fi;
+ newPath="$(PATH="${!pathName}" type -P "$(basename "$oldPath")" || true)";
+ args="$arg0 $args";
+ fi;
+ newInterpreterLine="$newPath $args";
+ newInterpreterLine=${newInterpreterLine%${newInterpreterLine##*[![:space:]]}};
+ if [[ -n "$oldPath" && ( "$update" == true || "${oldPath:0:${#NIX_STORE}}" != "$NIX_STORE" ) ]]; then
+ if [[ -n "$newPath" && "$newPath" != "$oldPath" ]]; then
+ echo "$f: interpreter directive changed from \"$oldInterpreterLine\" to \"$newInterpreterLine\"";
+ escapedInterpreterLine=${newInterpreterLine//\\/\\\\};
+ timestamp=$(stat --printf "%y" "$f");
+ tmpFile=$(mktemp -t patchShebangs.XXXXXXXXXX);
+ sed -e "1 s|.*|#\!$escapedInterpreterLine|" "$f" > "$tmpFile";
+ local restoreReadOnly;
+ if [[ ! -w "$f" ]]; then
+ chmod +w "$f";
+ restoreReadOnly=true;
+ fi;
+ cat "$tmpFile" > "$f";
+ rm "$tmpFile";
+ if [[ -n "${restoreReadOnly:-}" ]]; then
+ chmod -w "$f";
+ fi;
+ touch --date "$timestamp" "$f";
+ fi;
+ fi;
+ done < <(find "$@" -type f -perm -0100 -print0)
+}
+patchShebangsAuto ()
+{
+
+ if [[ -z "${dontPatchShebangs-}" && -e "$prefix" ]]; then
+ if [[ "$output" != out && "$output" = "$outputDev" ]]; then
+ patchShebangs --build "$prefix";
+ else
+ patchShebangs --host "$prefix";
+ fi;
+ fi
+}
+pkgConfigWrapper_addPkgConfigPath ()
+{
+
+ local role_post;
+ getHostRoleEnvHook;
+ addToSearchPath "PKG_CONFIG_PATH${role_post}" "$1/lib/pkgconfig";
+ addToSearchPath "PKG_CONFIG_PATH${role_post}" "$1/share/pkgconfig"
+}
+prependToVar ()
+{
+
+ local -n nameref="$1";
+ local useArray type;
+ if [ -n "$__structuredAttrs" ]; then
+ useArray=true;
+ else
+ useArray=false;
+ fi;
+ if type=$(declare -p "$1" 2> /dev/null); then
+ case "${type#* }" in
+ -A*)
+ echo "prependToVar(): ERROR: trying to use prependToVar on an associative array." 1>&2;
+ return 1
+ ;;
+ -a*)
+ useArray=true
+ ;;
+ *)
+ useArray=false
+ ;;
+ esac;
+ fi;
+ shift;
+ if $useArray; then
+ nameref=("$@" ${nameref+"${nameref[@]}"});
+ else
+ nameref="$* ${nameref-}";
+ fi
+}
+printLines ()
+{
+
+ (( "$#" > 0 )) || return 0;
+ printf '%s\n' "$@"
+}
+printPhases ()
+{
+
+ definePhases;
+ local phase;
+ for phase in ${phases[*]};
+ do
+ printf '%s\n' "$phase";
+ done
+}
+printWords ()
+{
+
+ (( "$#" > 0 )) || return 0;
+ printf '%s ' "$@"
+}
+recordPropagatedDependencies ()
+{
+
+ declare -ra flatVars=(depsBuildBuildPropagated propagatedNativeBuildInputs depsBuildTargetPropagated depsHostHostPropagated propagatedBuildInputs depsTargetTargetPropagated);
+ declare -ra flatFiles=("${propagatedBuildDepFiles[@]}" "${propagatedHostDepFiles[@]}" "${propagatedTargetDepFiles[@]}");
+ local propagatedInputsIndex;
+ for propagatedInputsIndex in "${!flatVars[@]}";
+ do
+ local propagatedInputsSlice="${flatVars[$propagatedInputsIndex]}[@]";
+ local propagatedInputsFile="${flatFiles[$propagatedInputsIndex]}";
+ [[ -n "${!propagatedInputsSlice}" ]] || continue;
+ mkdir -p "${!outputDev}/nix-support";
+ printWords ${!propagatedInputsSlice} > "${!outputDev}/nix-support/$propagatedInputsFile";
+ done
+}
+runHook ()
+{
+
+ local hookName="$1";
+ shift;
+ local hooksSlice="${hookName%Hook}Hooks[@]";
+ local hook;
+ for hook in "_callImplicitHook 0 $hookName" ${!hooksSlice+"${!hooksSlice}"};
+ do
+ _logHook "$hookName" "$hook" "$@";
+ _eval "$hook" "$@";
+ done;
+ return 0
+}
+runOneHook ()
+{
+
+ local hookName="$1";
+ shift;
+ local hooksSlice="${hookName%Hook}Hooks[@]";
+ local hook ret=1;
+ for hook in "_callImplicitHook 1 $hookName" ${!hooksSlice+"${!hooksSlice}"};
+ do
+ _logHook "$hookName" "$hook" "$@";
+ if _eval "$hook" "$@"; then
+ ret=0;
+ break;
+ fi;
+ done;
+ return "$ret"
+}
+runPhase ()
+{
+
+ local curPhase="$*";
+ if [[ "$curPhase" = unpackPhase && -n "${dontUnpack:-}" ]]; then
+ return;
+ fi;
+ if [[ "$curPhase" = patchPhase && -n "${dontPatch:-}" ]]; then
+ return;
+ fi;
+ if [[ "$curPhase" = configurePhase && -n "${dontConfigure:-}" ]]; then
+ return;
+ fi;
+ if [[ "$curPhase" = buildPhase && -n "${dontBuild:-}" ]]; then
+ return;
+ fi;
+ if [[ "$curPhase" = checkPhase && -z "${doCheck:-}" ]]; then
+ return;
+ fi;
+ if [[ "$curPhase" = installPhase && -n "${dontInstall:-}" ]]; then
+ return;
+ fi;
+ if [[ "$curPhase" = fixupPhase && -n "${dontFixup:-}" ]]; then
+ return;
+ fi;
+ if [[ "$curPhase" = installCheckPhase && -z "${doInstallCheck:-}" ]]; then
+ return;
+ fi;
+ if [[ "$curPhase" = distPhase && -z "${doDist:-}" ]]; then
+ return;
+ fi;
+ showPhaseHeader "$curPhase";
+ dumpVars;
+ local startTime endTime;
+ startTime=$(date +"%s");
+ eval "${!curPhase:-$curPhase}";
+ endTime=$(date +"%s");
+ showPhaseFooter "$curPhase" "$startTime" "$endTime";
+ if [ "$curPhase" = unpackPhase ]; then
+ [ -n "${sourceRoot:-}" ] && chmod +x -- "${sourceRoot}";
+ cd -- "${sourceRoot:-.}";
+ fi
+}
+showPhaseFooter ()
+{
+
+ local phase="$1";
+ local startTime="$2";
+ local endTime="$3";
+ local delta=$(( endTime - startTime ));
+ (( delta < 30 )) && return;
+ local H=$((delta/3600));
+ local M=$((delta%3600/60));
+ local S=$((delta%60));
+ echo -n "$phase completed in ";
+ (( H > 0 )) && echo -n "$H hours ";
+ (( M > 0 )) && echo -n "$M minutes ";
+ echo "$S seconds"
+}
+showPhaseHeader ()
+{
+
+ local phase="$1";
+ echo "Running phase: $phase";
+ if [[ -z ${NIX_LOG_FD-} ]]; then
+ return;
+ fi;
+ printf "@nix { \"action\": \"setPhase\", \"phase\": \"%s\" }\n" "$phase" >&"$NIX_LOG_FD"
+}
+stripDirs ()
+{
+
+ local cmd="$1";
+ local ranlibCmd="$2";
+ local paths="$3";
+ local stripFlags="$4";
+ local excludeFlags=();
+ local pathsNew=;
+ [ -z "$cmd" ] && echo "stripDirs: Strip command is empty" 1>&2 && exit 1;
+ [ -z "$ranlibCmd" ] && echo "stripDirs: Ranlib command is empty" 1>&2 && exit 1;
+ local pattern;
+ if [ -n "${stripExclude:-}" ]; then
+ for pattern in "${stripExclude[@]}";
+ do
+ excludeFlags+=(-a '!' '(' -name "$pattern" -o -wholename "$prefix/$pattern" ')');
+ done;
+ fi;
+ local p;
+ for p in ${paths};
+ do
+ if [ -e "$prefix/$p" ]; then
+ pathsNew="${pathsNew} $prefix/$p";
+ fi;
+ done;
+ paths=${pathsNew};
+ if [ -n "${paths}" ]; then
+ echo "stripping (with command $cmd and flags $stripFlags) in $paths";
+ local striperr;
+ striperr="$(mktemp --tmpdir="$TMPDIR" 'striperr.XXXXXX')";
+ find $paths -type f "${excludeFlags[@]}" -a '!' -path "$prefix/lib/debug/*" -printf '%D-%i,%p\0' | sort -t, -k1,1 -u -z | cut -d, -f2- -z | xargs -r -0 -n1 -P "$NIX_BUILD_CORES" -- $cmd $stripFlags 2> "$striperr" || exit_code=$?;
+ [[ "$exit_code" = 123 || -z "$exit_code" ]] || ( cat "$striperr" 1>&2 && exit 1 );
+ rm "$striperr";
+ find $paths -name '*.a' -type f -exec $ranlibCmd '{}' \; 2> /dev/null;
+ fi
+}
+stripHash ()
+{
+
+ local strippedName casematchOpt=0;
+ strippedName="$(basename -- "$1")";
+ shopt -q nocasematch && casematchOpt=1;
+ shopt -u nocasematch;
+ if [[ "$strippedName" =~ ^[a-z0-9]{32}- ]]; then
+ echo "${strippedName:33}";
+ else
+ echo "$strippedName";
+ fi;
+ if (( casematchOpt )); then
+ shopt -s nocasematch;
+ fi
+}
+substitute ()
+{
+
+ local input="$1";
+ local output="$2";
+ shift 2;
+ if [ ! -f "$input" ]; then
+ echo "substitute(): ERROR: file '$input' does not exist" 1>&2;
+ return 1;
+ fi;
+ local content;
+ consumeEntire content < "$input";
+ if [ -e "$output" ]; then
+ chmod +w "$output";
+ fi;
+ substituteStream content "file '$input'" "$@" > "$output"
+}
+substituteAll ()
+{
+
+ local input="$1";
+ local output="$2";
+ local -a args=();
+ _allFlags;
+ substitute "$input" "$output" "${args[@]}"
+}
+substituteAllInPlace ()
+{
+
+ local fileName="$1";
+ shift;
+ substituteAll "$fileName" "$fileName" "$@"
+}
+substituteAllStream ()
+{
+
+ local -a args=();
+ _allFlags;
+ substituteStream "$1" "$2" "${args[@]}"
+}
+substituteInPlace ()
+{
+
+ local -a fileNames=();
+ for arg in "$@";
+ do
+ if [[ "$arg" = "--"* ]]; then
+ break;
+ fi;
+ fileNames+=("$arg");
+ shift;
+ done;
+ if ! [[ "${#fileNames[@]}" -gt 0 ]]; then
+ echo "substituteInPlace called without any files to operate on (files must come before options!)" 1>&2;
+ return 1;
+ fi;
+ for file in "${fileNames[@]}";
+ do
+ substitute "$file" "$file" "$@";
+ done
+}
+substituteStream ()
+{
+
+ local var=$1;
+ local description=$2;
+ shift 2;
+ while (( "$#" )); do
+ local replace_mode="$1";
+ case "$1" in
+ --replace)
+ if ! "$_substituteStream_has_warned_replace_deprecation"; then
+ echo "substituteStream() in derivation $name: WARNING: '--replace' is deprecated, use --replace-{fail,warn,quiet}. ($description)" 1>&2;
+ _substituteStream_has_warned_replace_deprecation=true;
+ fi;
+ replace_mode='--replace-warn'
+ ;&
+ --replace-quiet | --replace-warn | --replace-fail)
+ pattern="$2";
+ replacement="$3";
+ shift 3;
+ if ! [[ "${!var}" == *"$pattern"* ]]; then
+ if [ "$replace_mode" == --replace-warn ]; then
+ printf "substituteStream() in derivation $name: WARNING: pattern %q doesn't match anything in %s\n" "$pattern" "$description" 1>&2;
+ else
+ if [ "$replace_mode" == --replace-fail ]; then
+ printf "substituteStream() in derivation $name: ERROR: pattern %q doesn't match anything in %s\n" "$pattern" "$description" 1>&2;
+ return 1;
+ fi;
+ fi;
+ fi;
+ eval "$var"'=${'"$var"'//"$pattern"/"$replacement"}'
+ ;;
+ --subst-var)
+ local varName="$2";
+ shift 2;
+ if ! [[ "$varName" =~ ^[a-zA-Z_][a-zA-Z0-9_]*$ ]]; then
+ echo "substituteStream() in derivation $name: ERROR: substitution variables must be valid Bash names, \"$varName\" isn't." 1>&2;
+ return 1;
+ fi;
+ if [ -z ${!varName+x} ]; then
+ echo "substituteStream() in derivation $name: ERROR: variable \$$varName is unset" 1>&2;
+ return 1;
+ fi;
+ pattern="@$varName@";
+ replacement="${!varName}";
+ eval "$var"'=${'"$var"'//"$pattern"/"$replacement"}'
+ ;;
+ --subst-var-by)
+ pattern="@$2@";
+ replacement="$3";
+ eval "$var"'=${'"$var"'//"$pattern"/"$replacement"}';
+ shift 3
+ ;;
+ *)
+ echo "substituteStream() in derivation $name: ERROR: Invalid command line argument: $1" 1>&2;
+ return 1
+ ;;
+ esac;
+ done;
+ printf "%s" "${!var}"
+}
+unpackFile ()
+{
+
+ curSrc="$1";
+ echo "unpacking source archive $curSrc";
+ if ! runOneHook unpackCmd "$curSrc"; then
+ echo "do not know how to unpack source archive $curSrc";
+ exit 1;
+ fi
+}
+unpackPhase ()
+{
+
+ runHook preUnpack;
+ if [ -z "${srcs:-}" ]; then
+ if [ -z "${src:-}" ]; then
+ echo 'variable $src or $srcs should point to the source';
+ exit 1;
+ fi;
+ srcs="$src";
+ fi;
+ local -a srcsArray;
+ concatTo srcsArray srcs;
+ local dirsBefore="";
+ for i in *;
+ do
+ if [ -d "$i" ]; then
+ dirsBefore="$dirsBefore $i ";
+ fi;
+ done;
+ for i in "${srcsArray[@]}";
+ do
+ unpackFile "$i";
+ done;
+ : "${sourceRoot=}";
+ if [ -n "${setSourceRoot:-}" ]; then
+ runOneHook setSourceRoot;
+ else
+ if [ -z "$sourceRoot" ]; then
+ for i in *;
+ do
+ if [ -d "$i" ]; then
+ case $dirsBefore in
+ *\ $i\ *)
+
+ ;;
+ *)
+ if [ -n "$sourceRoot" ]; then
+ echo "unpacker produced multiple directories";
+ exit 1;
+ fi;
+ sourceRoot="$i"
+ ;;
+ esac;
+ fi;
+ done;
+ fi;
+ fi;
+ if [ -z "$sourceRoot" ]; then
+ echo "unpacker appears to have produced no directories";
+ exit 1;
+ fi;
+ echo "source root is $sourceRoot";
+ if [ "${dontMakeSourcesWritable:-0}" != 1 ]; then
+ chmod -R u+w -- "$sourceRoot";
+ fi;
+ runHook postUnpack
+}
+updateAutotoolsGnuConfigScriptsPhase ()
+{
+
+ if [ -n "${dontUpdateAutotoolsGnuConfigScripts-}" ]; then
+ return;
+ fi;
+ for script in config.sub config.guess;
+ do
+ for f in $(find . -type f -name "$script");
+ do
+ echo "Updating Autotools / GNU config script to a newer upstream version: $f";
+ cp -f "/nix/store/dikzgw0kinvli9fdhv7qxqbbmirilbn3-gnu-config-2024-01-01/$script" "$f";
+ done;
+ done
+}
+updateSourceDateEpoch ()
+{
+
+ local path="$1";
+ [[ $path == -* ]] && path="./$path";
+ local -a res=($(find "$path" -type f -not -newer "$NIX_BUILD_TOP/.." -printf '%T@ "%p"\0' | sort -n --zero-terminated | tail -n1 --zero-terminated | head -c -1));
+ local time="${res[0]//\.[0-9]*/}";
+ local newestFile="${res[1]}";
+ if [ "${time:-0}" -gt "$SOURCE_DATE_EPOCH" ]; then
+ echo "setting SOURCE_DATE_EPOCH to timestamp $time of file $newestFile";
+ export SOURCE_DATE_EPOCH="$time";
+ local now="$(date +%s)";
+ if [ "$time" -gt $((now - 60)) ]; then
+ echo "warning: file $newestFile may be generated; SOURCE_DATE_EPOCH may be non-deterministic";
+ fi;
+ fi
+}
+PATH="$PATH${nix_saved_PATH:+:$nix_saved_PATH}"
+XDG_DATA_DIRS="$XDG_DATA_DIRS${nix_saved_XDG_DATA_DIRS:+:$nix_saved_XDG_DATA_DIRS}"
+export NIX_BUILD_TOP="$(mktemp -d -t nix-shell.XXXXXX)"
+export TMP="$NIX_BUILD_TOP"
+export TMPDIR="$NIX_BUILD_TOP"
+export TEMP="$NIX_BUILD_TOP"
+export TEMPDIR="$NIX_BUILD_TOP"
+eval "${shellHook:-}"
diff --git a/Cargo.lock b/Cargo.lock
index 04c00a6c..9d1e0b6e 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -76,6 +76,16 @@ dependencies = [
]
[[package]]
+name = "animation"
+version = "0.1.0"
+dependencies = [
+ "scene",
+ "serde",
+ "serde_json",
+ "time",
+]
+
+[[package]]
name = "anyhow"
version = "1.0.102"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -95,6 +105,9 @@ name = "arbitrary"
version = "1.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1"
+dependencies = [
+ "derive_arbitrary",
+]
[[package]]
name = "arg_enum_proc_macro"
@@ -512,6 +525,24 @@ dependencies = [
]
[[package]]
+name = "bindgen"
+version = "0.72.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895"
+dependencies = [
+ "bitflags 2.11.0",
+ "cexpr",
+ "clang-sys",
+ "itertools 0.13.0",
+ "proc-macro2",
+ "quote",
+ "regex",
+ "rustc-hash 2.1.1",
+ "shlex",
+ "syn 2.0.117",
+]
+
+[[package]]
name = "bit-set"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -836,6 +867,20 @@ dependencies = [
]
[[package]]
+name = "chrono"
+version = "0.4.45"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327"
+dependencies = [
+ "iana-time-zone",
+ "js-sys",
+ "num-traits",
+ "serde",
+ "wasm-bindgen",
+ "windows-link 0.2.1",
+]
+
+[[package]]
name = "cipher"
version = "0.4.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -956,6 +1001,21 @@ dependencies = [
]
[[package]]
+name = "commands"
+version = "0.1.0"
+dependencies = [
+ "animation",
+ "chrono",
+ "scene",
+ "serde",
+ "serde_json",
+ "thiserror 2.0.18",
+ "time",
+ "timeline",
+ "uuid",
+]
+
+[[package]]
name = "compositor"
version = "0.1.0"
dependencies = [
@@ -1198,6 +1258,30 @@ dependencies = [
]
[[package]]
+name = "cosmic-text"
+version = "0.15.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "173852283a9a57a3cbe365d86e74dc428a09c50421477d5ad6fe9d9509e37737"
+dependencies = [
+ "bitflags 2.11.0",
+ "fontdb 0.23.0",
+ "harfrust",
+ "linebender_resource_handle",
+ "log",
+ "rangemap",
+ "rustc-hash 1.1.0",
+ "self_cell",
+ "skrifa 0.37.0",
+ "smol_str",
+ "swash",
+ "sys-locale",
+ "unicode-bidi",
+ "unicode-linebreak",
+ "unicode-script",
+ "unicode-segmentation",
+]
+
+[[package]]
name = "cpufeatures"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1295,6 +1379,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac6b926516df9c60bfa16e107b21086399f8285a44ca9711344b9e553c5146e2"
[[package]]
+name = "derive_arbitrary"
+version = "1.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
+[[package]]
name = "derive_more"
version = "0.99.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1450,6 +1545,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555"
[[package]]
+name = "effect-defs"
+version = "0.1.0"
+dependencies = [
+ "effects",
+ "scene",
+ "thiserror 2.0.18",
+]
+
+[[package]]
name = "effects"
version = "0.1.0"
dependencies = [
@@ -1668,6 +1772,31 @@ dependencies = [
]
[[package]]
+name = "ffmpeg-next"
+version = "8.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f7c4bd5ab1ac61f29c634df1175d350ded29cf74c3c6d4f7030431a5ae3c7d5d"
+dependencies = [
+ "bitflags 2.11.0",
+ "ffmpeg-sys-next",
+ "libc",
+]
+
+[[package]]
+name = "ffmpeg-sys-next"
+version = "8.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a314bc0e022a33a99567ed4bd2576bd58ffd8fcff7891c29194cfecc26a62547"
+dependencies = [
+ "bindgen 0.72.1",
+ "cc",
+ "libc",
+ "num_cpus",
+ "pkg-config",
+ "vcpkg",
+]
+
+[[package]]
name = "filedescriptor"
version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1755,6 +1884,15 @@ checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
[[package]]
name = "font-types"
+version = "0.10.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "39a654f404bbcbd48ea58c617c2993ee91d1cb63727a37bf2323a4edeed1b8c5"
+dependencies = [
+ "bytemuck",
+]
+
+[[package]]
+name = "font-types"
version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "73829a7b5c91198af28a99159b7ae4afbb252fb906159ff7f189f3a2ceaa3df2"
@@ -2215,7 +2353,7 @@ dependencies = [
"as-raw-xcb-connection",
"ashpd 0.11.1",
"async-task",
- "bindgen",
+ "bindgen 0.71.1",
"blade-graphics",
"blade-macros",
"blade-util",
@@ -2231,7 +2369,7 @@ dependencies = [
"core-graphics 0.24.0",
"core-text",
"core-video",
- "cosmic-text",
+ "cosmic-text 0.14.2",
"ctor",
"derive_more",
"embed-resource",
@@ -2367,7 +2505,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "05cb8912ae17371725132d2b7eec6797a255accc95d58ee5c1134b529810f14b"
dependencies = [
"anyhow",
- "bindgen",
+ "bindgen 0.71.1",
"core-foundation 0.10.0",
"core-video",
"ctor",
@@ -2465,6 +2603,13 @@ dependencies = [
]
[[package]]
+name = "graphics"
+version = "0.1.0"
+dependencies = [
+ "scene",
+]
+
+[[package]]
name = "grid"
version = "0.18.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2502,6 +2647,19 @@ dependencies = [
]
[[package]]
+name = "harfrust"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "92c020db12c71d8a12a3fe7607873cade3a01a6287e29d540c8723276221b9d8"
+dependencies = [
+ "bitflags 2.11.0",
+ "bytemuck",
+ "core_maths",
+ "read-fonts 0.35.0",
+ "smallvec",
+]
+
+[[package]]
name = "hashbrown"
version = "0.14.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2694,6 +2852,30 @@ dependencies = [
]
[[package]]
+name = "iana-time-zone"
+version = "0.1.65"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470"
+dependencies = [
+ "android_system_properties",
+ "core-foundation-sys",
+ "iana-time-zone-haiku",
+ "js-sys",
+ "log",
+ "wasm-bindgen",
+ "windows-core 0.62.2",
+]
+
+[[package]]
+name = "iana-time-zone-haiku"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f"
+dependencies = [
+ "cc",
+]
+
+[[package]]
name = "icu_collections"
version = "2.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -3127,6 +3309,12 @@ dependencies = [
]
[[package]]
+name = "linebender_resource_handle"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d4a5ff6bcca6c4867b1c4fd4ef63e4db7436ef363e0ad7531d1558856bae64f4"
+
+[[package]]
name = "linux-raw-sys"
version = "0.4.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -3252,6 +3440,14 @@ dependencies = [
]
[[package]]
+name = "mask-defs"
+version = "0.1.0"
+dependencies = [
+ "scene",
+ "thiserror 2.0.18",
+]
+
+[[package]]
name = "masks"
version = "0.1.0"
dependencies = [
@@ -3281,6 +3477,15 @@ dependencies = [
]
[[package]]
+name = "media"
+version = "0.1.0"
+dependencies = [
+ "ffmpeg-next",
+ "thiserror 2.0.18",
+ "time",
+]
+
+[[package]]
name = "memchr"
version = "2.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -4013,6 +4218,13 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6"
[[package]]
+name = "playback"
+version = "0.1.0"
+dependencies = [
+ "time",
+]
+
+[[package]]
name = "png"
version = "0.17.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -4481,12 +4693,23 @@ dependencies = [
[[package]]
name = "read-fonts"
+version = "0.35.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6717cf23b488adf64b9d711329542ba34de147df262370221940dfabc2c91358"
+dependencies = [
+ "bytemuck",
+ "core_maths",
+ "font-types 0.10.1",
+]
+
+[[package]]
+name = "read-fonts"
version = "0.37.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7b634fabf032fab15307ffd272149b622260f55974d9fad689292a5d33df02e5"
dependencies = [
"bytemuck",
- "font-types",
+ "font-types 0.11.1",
]
[[package]]
@@ -4820,6 +5043,16 @@ dependencies = [
]
[[package]]
+name = "scene"
+version = "0.1.0"
+dependencies = [
+ "chrono",
+ "serde",
+ "serde_json",
+ "time",
+]
+
+[[package]]
name = "schannel"
version = "0.1.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -5124,12 +5357,22 @@ checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e"
[[package]]
name = "skrifa"
+version = "0.37.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8c31071dedf532758ecf3fed987cdb4bd9509f900e026ab684b4ecb81ea49841"
+dependencies = [
+ "bytemuck",
+ "read-fonts 0.35.0",
+]
+
+[[package]]
+name = "skrifa"
version = "0.40.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7fbdfe3d2475fbd7ddd1f3e5cf8288a30eb3e5f95832829570cd88115a7434ac"
dependencies = [
"bytemuck",
- "read-fonts",
+ "read-fonts 0.37.0",
]
[[package]]
@@ -5260,6 +5503,20 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
[[package]]
+name = "storage"
+version = "0.1.0"
+dependencies = [
+ "chrono",
+ "scene",
+ "serde",
+ "serde_json",
+ "tempfile",
+ "thiserror 2.0.18",
+ "time",
+ "transfer",
+]
+
+[[package]]
name = "strict-num"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -5417,7 +5674,7 @@ version = "0.2.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "842f3cd369c2ba38966204f983eaa5e54a8e84a7d7159ed36ade2b6c335aae64"
dependencies = [
- "skrifa",
+ "skrifa 0.40.0",
"yazi",
"zeno",
]
@@ -5572,6 +5829,15 @@ dependencies = [
]
[[package]]
+name = "text"
+version = "0.1.0"
+dependencies = [
+ "cosmic-text 0.15.0",
+ "scene",
+ "thiserror 2.0.18",
+]
+
+[[package]]
name = "thiserror"
version = "1.0.69"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -5637,6 +5903,18 @@ dependencies = [
]
[[package]]
+name = "timeline"
+version = "0.1.0"
+dependencies = [
+ "animation",
+ "chrono",
+ "scene",
+ "serde",
+ "serde_json",
+ "time",
+]
+
+[[package]]
name = "tiny-keccak"
version = "2.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -5905,6 +6183,16 @@ dependencies = [
]
[[package]]
+name = "transfer"
+version = "0.1.0"
+dependencies = [
+ "serde",
+ "serde_json",
+ "thiserror 2.0.18",
+ "zip",
+]
+
+[[package]]
name = "try-lock"
version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -6183,6 +6471,12 @@ dependencies = [
]
[[package]]
+name = "vcpkg"
+version = "0.2.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
+
+[[package]]
name = "version_check"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -7814,6 +8108,21 @@ dependencies = [
]
[[package]]
+name = "zip"
+version = "2.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fabe6324e908f85a1c52063ce7aa26b68dcb7eb6dbc83a2d148403c9bc3eba50"
+dependencies = [
+ "arbitrary",
+ "crc32fast",
+ "crossbeam-utils",
+ "displaydoc",
+ "indexmap",
+ "memchr",
+ "thiserror 2.0.18",
+]
+
+[[package]]
name = "zmij"
version = "1.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
diff --git a/Cargo.toml b/Cargo.toml
index b00c78e8..5c10d41d 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -7,5 +7,17 @@ members = [
"rust/crates/effects",
"rust/crates/gpu",
"rust/crates/masks",
+ "rust/crates/scene",
+ "rust/crates/timeline",
+ "rust/crates/animation",
+ "rust/crates/commands",
+ "rust/crates/media",
+ "rust/crates/transfer",
+ "rust/crates/storage",
+ "rust/crates/text",
+ "rust/crates/graphics",
+ "rust/crates/effect-defs",
+ "rust/crates/mask-defs",
+ "rust/crates/playback",
"rust/wasm", "rust/crates/compositor",
]
diff --git a/flake.nix b/flake.nix
index fab10705..8edaccbc 100644
--- a/flake.nix
+++ b/flake.nix
@@ -25,7 +25,48 @@
bun
just
nodejs
+
+ cargo
+ clippy
+ rustc
+ rustfmt
+
+ cmake
+ pkg-config
+
+ alsa-lib
+ fontconfig
+ freetype
+ libxkbcommon
+ openssl
+ vulkan-loader
+ wayland
+ libX11
+ libxcb
+
+ ffmpeg-headless.dev
+ llvmPackages.libclang
];
+
+ env.LIBCLANG_PATH = "${pkgs.llvmPackages.libclang.lib}/lib";
+
+ shellHook = ''
+ export BINDGEN_EXTRA_CLANG_ARGS="$(cat ${pkgs.stdenv.cc}/nix-support/cc-cflags 2>/dev/null) $(cat ${pkgs.stdenv.cc}/nix-support/libc-cflags 2>/dev/null) $BINDGEN_EXTRA_CLANG_ARGS"
+ export LD_LIBRARY_PATH=${
+ pkgs.lib.makeLibraryPath (
+ with pkgs; [
+ alsa-lib
+ fontconfig
+ freetype
+ libxkbcommon
+ vulkan-loader
+ wayland
+ libX11
+ libxcb
+ ]
+ )
+ }:$LD_LIBRARY_PATH
+ '';
};
}
);
diff --git a/notes/gpui-port.md b/notes/gpui-port.md
new file mode 100644
index 00000000..853d65d3
--- /dev/null
+++ b/notes/gpui-port.md
@@ -0,0 +1,98 @@
+# GPUI desktop port — architecture & plan
+
+Captured 2026-07-28. Owner decisions this session:
+
+- **Full 1:1 port** of the editor to GPUI. Linux-first; macOS/Windows not targeted for now, BSD parked (GPUI support unproven).
+- **In-process ffmpeg** via `ffmpeg-next` for decode AND encode. No subprocess.
+- **Licensing is not a constraint** — x264 (GPL) is the H.264 encoder; quality over AppImage-ability. The documented OpenH264 dead end (ignores bitrate, undershoots ~10x) is bypassed entirely.
+- Web version stays and keeps working; the browser editor remains a feature.
+
+## Why the port
+
+Browser inconsistency (WebGPU quirks, encoder behavior varying by browser)
+keeps producing walls. A native app gives one controlled runtime: wgpu +
+ffmpeg, one decoder, one encoder, one compositor.
+
+## What "backend" means here
+
+There is no server worth porting: the Next.js backend is an auth scaffold
+(no flows implemented), a health route, and a sounds proxy (sounds are
+feature-flagged off). The real "backend" is the offline project store
+(IndexedDB) — on desktop it becomes a plain filesystem layout per project.
+So: no Rust server, no API. Everything lives in the app process.
+
+## Crate map
+
+Existing, reused natively as-is (already target-agnostic):
+
+- `rust/crates/time` — media time, frame rate, timecode
+- `rust/crates/compositor` + `effects` + `masks` + `gpu` — wgpu compositor,
+ WGSL effect pipeline, SDF mask feathering. The web drives this through
+ wasm; desktop links it directly.
+
+New crates:
+
+- `scene` — project/scene graph model, serde, `.ocp` project.json compat
+ (round-trip proven against the web's SerializedProject shape).
+- `timeline` — tracks, elements, placement, snapping, split/trim/resize,
+ ripple, grouping. Pure logic, no UI.
+- `animation` — keyframes, bezier interpolation, channel sampling.
+- `commands` — undo/redo command stack (ports `apps/web/src/commands`).
+- `media` — ffmpeg-next demux/decode, frame pipeline to GPU textures,
+ thumbnails, waveform extraction.
+- `audio` — decode, mixing, rate stretching, mastering.
+- `encode` — export pipeline: compositor frames → x264/VP9, muxing.
+- `transfer` — `.ocp` zip container build/parse (cross-compat with the web's
+ fflate archives proven in both directions).
+- `storage` — project dirs on disk, per-project media store, autosave,
+ `.ocp` import/export (replaces IndexedDB; stays offline-first).
+- `text` — text layout/measure/raster via cosmic-text (ports
+ `apps/web/src/text`).
+- `graphics` — gradient parser + shape raster via tiny-skia (ports
+ `apps/web/src/graphics` + `gradients`).
+- `effect-defs` — effect definitions registry: params → WGSL pass
+ descriptors (ports `apps/web/src/effects/definitions`).
+- `mask-defs` — mask shape renderers via tiny-skia (ports
+ `apps/web/src/masks/builtin` + `freeform`).
+- `renderer` — scene → node tree → resolve at time t → compositor
+ FrameDescriptor (ports `apps/web/src/services/renderer`). Integration hub;
+ starts once the leaf crates land.
+- `playback` — clock, frame scheduling, audio sync.
+
+`apps/desktop` (GPUI) holds UI only; all logic lives in crates so the web
+version can adopt them later via wasm where it makes sense.
+
+## Compositor → GPUI bridge
+
+GPUI does not expose its internal GPU device. v1 approach: the compositor
+owns its own wgpu device, renders the frame to an offscreen texture,
+reads back, and paints it as a `gpui::Image` in a custom element. ~8 MB
+per 1080p frame — fine for preview; revisit if profiling says otherwise.
+
+## Milestones (each runnable)
+
+1. **Environment** — nix devshell with rust + GPUI native deps + ffmpeg dev.
+2. **Domain model** — `scene` crate: full project/scene/element model
+ ported from `apps/web/src/timeline/types.ts` + scene managers, `.ocp`
+ round-trip compat proven against web-generated archives.
+3. **Timeline core** — `timeline`, `animation`, `commands` crates with
+ ported unit tests (web test suite is the behavior spec).
+4. **Media** — `media`/`audio` crates: decode → texture, waveform,
+ thumbnails.
+5. **Compositor bridge** — scene resolve → wgpu frame → GPUI preview.
+6. **GPUI UI** — projects screen, editor layout, timeline UI (drag, trim,
+ split, snap, ripple), preview overlays/selection, all panels.
+7. **Export** — `encode` crate + dialog, x264 quality ladder.
+8. **Features** — versions, voiceover (cpal), clipboard, keybindings,
+ `.ocp` import/export.
+9. **Packaging** — .desktop entry, binary distribution. AppImage optional.
+
+## Port rules
+
+- Web behavior is the spec. Where the web has unit tests, port the test
+ with the code.
+- Flagged-off web features (sounds, stickers, effects domains) are NOT
+ ported — they are slated for deletion upstream of us.
+- `.ocp` archives must round-trip between web and desktop.
+- No parallel divergence in shared crates: `compositor`/`time` changes must
+ keep compiling for `wasm32`.
diff --git a/notes/roadmap.md b/notes/roadmap.md
index c886edeb..fc89bb46 100644
--- a/notes/roadmap.md
+++ b/notes/roadmap.md
@@ -1,10 +1,21 @@
# Roadmap & session notes
Working notes for the fork's development direction. Captured 2026-07-26,
-updated 2026-07-27 (roadmap feature slices implemented, last commit
-`9c3b788b`).
+updated 2026-07-28 (GPUI desktop port greenlit — see notes/gpui-port.md).
-## Done
+## Done (2026-07-28 session)
+
+- Freeze-duration prompt removed; freeze frame uses a fixed 3s (store, dialog, and test deleted).
+- Ripple-extend: dragging the right edge of a static element (image/freeze still/text, i.e. no `sourceDuration`) past its neighbor pushes the whole right-hand chain on that track. Same undo step as the resize; live during drag. Video/audio keep clamping. Core: `timeline/group-resize/compute-resize.ts` + `rightPushChain` from `resize-controller.ts`.
+- Timeline filmstrip: video elements render real per-position thumbnails, density adapts to zoom (power-of-two interval snapping). New `services/thumbnail-cache/` (LRU, batched mediabunny decode) + `timeline/components/video-filmstrip.tsx`. Images unchanged. Verified in browser by agent (distinct tiles, zoom density, viewport clipping, cache clear on media delete).
+- Export hardening (`services/renderer/scene-exporter.ts`, `export-encoding.ts`): bitrate is fps-aware (`w*h*fps*bpp`, high = 0.2 bpp → ~12.4 Mbps at 1080p30, was ~6 fps-blind); keyframe every 2s; export canvas rounded to even dims. A capture-timing change (wait past WebGPU present before snapshot) was tried and REVERTED the same day — the canvas is blank after present, export came out black. Synchronous same-task snapshot is the correct behavior for WebGPU canvases; the temporal-ghosting hypothesis is dead. Colorspace tagging: no API exists (verified mediabunny + lib.dom) — BT.709 vs 601 is Chrome's encoder's call.
+- MP4 export: ffmpeg.wasm + libx264 was implemented (chunked rawvideo → x264 CRF ladder) and then REVERTED at the owner's request after two wasm-side bugs (x264 auto-threading spawns 28 pthreads and hangs the worker — `-threads 4` fixes it; `@ffmpeg/ffmpeg` `writeFile` transfers/detaches the input ArrayBuffer, so chunk buffers must be single-use). The investigation notes stay here in case the idea is revisited; WebM/VP9 export is the owner's daily path and works well. MP4 stays on the mediabunny/WebCodecs avc path (which undershoots bitrate on this machine — OpenH264 ignores bitrateMode, has no quantizer mode, no hardware encoder exists; probe-verified 2026-07-28).
+- Encoder investigation (2026-07-28, probe-verified in the user's Helium): NO hardware H.264 encode exists on the machine; Chrome's OpenH264 ignores `bitrateMode` (byte-identical VBR/CBR output), has no AVC quantizer mode (`isConfigSupported` false), and undershoots to ~0.6 Mbps on real content regardless of configured bitrate (6-50 Mbps). WebCodecs H.264 is a dead end for quality export; x264 via wasm is the only in-browser path. The fps-aware `computeVideoBitrate` still serves the WebM path.
+- Next dev overlay disabled (`devIndicators: false` in `apps/web/next.config.ts`).
+- Gates green: bun test 316 pass / 1 skip, tsc, eslint on touched files, `bun build:web`.
+- Human verification still owed: freeze + ripple-extend click-through by owner.
+
+## Done (2026-07-26/27 session)
- Freeze frame at playhead (snowflake button), composite canvas capture.
- Freeze frame is a single undo step (`BatchCommand` + `ShiftSplitRemainderCommand`).
@@ -69,13 +80,12 @@ work. The GPUI desktop app sidesteps it entirely (native GPU).
5. ~~Voice-over recording~~ done.
6. ~~Git-like project versioning~~ done (v1). v2 candidates: diff view,
branches, content-addressed media dedup, shipping history inside `.ocp`.
-7. **Self-contained binary, first-class Linux + BSD.** `apps/desktop` (GPUI)
- is the vehicle but is a hello-world scaffold today; porting the editor is
- a large effort. Caveat: GPUI's Linux support is young; BSD support is
- unproven — spike before committing. Browser version stays regardless
- (editing in a browser is a feature, not a compromise). Distribution:
- AppImage/GitHub releases first; Flathub is effectively closed to us (June
- 2026 policy bans AI-assisted code in new submissions).
+7. ~~Self-contained binary, first-class Linux + BSD.~~ **GREENLIT
+ 2026-07-28: full 1:1 GPUI port, Linux-first.** Decisions: in-process
+ ffmpeg (`ffmpeg-next`) for decode+encode, x264 for H.264 (licensing
+ waived, quality first), no server backend (project store moves to
+ filesystem), web version stays. BSD parked (GPUI unproven there).
+ Full plan: notes/gpui-port.md.
## Deprioritized / parked
diff --git a/rust/crates/animation/Cargo.toml b/rust/crates/animation/Cargo.toml
new file mode 100644
index 00000000..bd21351e
--- /dev/null
+++ b/rust/crates/animation/Cargo.toml
@@ -0,0 +1,10 @@
+[package]
+name = "animation"
+version = "0.1.0"
+edition = "2024"
+
+[dependencies]
+scene = { version = "0.1.0", path = "../scene" }
+serde = { version = "1.0", features = ["derive"] }
+serde_json = "1.0"
+time = { version = "0.1.0", path = "../time" }
diff --git a/rust/crates/animation/src/bezier.rs b/rust/crates/animation/src/bezier.rs
new file mode 100644
index 00000000..6bec252f
--- /dev/null
+++ b/rust/crates/animation/src/bezier.rs
@@ -0,0 +1,156 @@
+use scene::{CurveHandle, ScalarAnimationKey};
+use time::MediaTime;
+
+pub const BEZIER_SOLVE_ITERATIONS: usize = 20;
+
+#[derive(Clone, Copy, Debug, PartialEq)]
+pub struct BezierHandle {
+ pub dt: f64,
+ pub dv: f64,
+}
+
+impl From<CurveHandle> for BezierHandle {
+ fn from(handle: CurveHandle) -> Self {
+ Self {
+ dt: handle.dt.as_ticks() as f64,
+ dv: handle.dv,
+ }
+ }
+}
+
+pub fn bezier_point(progress: f64, p0: f64, p1: f64, p2: f64, p3: f64) -> f64 {
+ let mt = 1.0 - progress;
+ mt * mt * mt * p0
+ + 3.0 * mt * mt * progress * p1
+ + 3.0 * mt * progress * progress * p2
+ + progress * progress * progress * p3
+}
+
+pub fn default_right_handle(
+ left_key: &ScalarAnimationKey,
+ right_key: &ScalarAnimationKey,
+) -> BezierHandle {
+ let span = (right_key.time - left_key.time).as_ticks() as f64;
+ let value_delta = right_key.value - left_key.value;
+ BezierHandle {
+ dt: span / 3.0,
+ dv: value_delta / 3.0,
+ }
+}
+
+pub fn default_left_handle(
+ left_key: &ScalarAnimationKey,
+ right_key: &ScalarAnimationKey,
+) -> BezierHandle {
+ let span = (right_key.time - left_key.time).as_ticks() as f64;
+ let value_delta = right_key.value - left_key.value;
+ BezierHandle {
+ dt: -span / 3.0,
+ dv: -value_delta / 3.0,
+ }
+}
+
+pub fn segment_handles(
+ left_key: &ScalarAnimationKey,
+ right_key: &ScalarAnimationKey,
+) -> (BezierHandle, BezierHandle) {
+ let right_handle = left_key
+ .right_handle
+ .map(BezierHandle::from)
+ .unwrap_or_else(|| default_right_handle(left_key, right_key));
+ let left_handle = right_key
+ .left_handle
+ .map(BezierHandle::from)
+ .unwrap_or_else(|| default_left_handle(left_key, right_key));
+ (right_handle, left_handle)
+}
+
+pub fn solve_bezier_progress_for_time(
+ time: MediaTime,
+ left_key: &ScalarAnimationKey,
+ right_key: &ScalarAnimationKey,
+) -> f64 {
+ let (right_handle, left_handle) = segment_handles(left_key, right_key);
+ let target = time.as_ticks() as f64;
+ let p0 = left_key.time.as_ticks() as f64;
+ let p3 = right_key.time.as_ticks() as f64;
+ let mut lower = 0.0;
+ let mut upper = 1.0;
+
+ for _ in 0..BEZIER_SOLVE_ITERATIONS {
+ let mid = (lower + upper) / 2.0;
+ let estimate = bezier_point(mid, p0, p0 + right_handle.dt, p3 + left_handle.dt, p3);
+ if estimate < target {
+ lower = mid;
+ } else {
+ upper = mid;
+ }
+ }
+
+ (lower + upper) / 2.0
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use scene::{ScalarSegmentType, TangentMode};
+
+ fn key(time: i64, value: f64) -> ScalarAnimationKey {
+ ScalarAnimationKey {
+ id: format!("k{time}"),
+ time: MediaTime::from_ticks(time),
+ value,
+ left_handle: None,
+ right_handle: None,
+ segment_to_next: ScalarSegmentType::Bezier,
+ tangent_mode: TangentMode::Flat,
+ }
+ }
+
+ #[test]
+ fn bezier_point_evaluates_cubic() {
+ assert_eq!(bezier_point(0.0, 0.0, 1.0, 2.0, 3.0), 0.0);
+ assert_eq!(bezier_point(1.0, 0.0, 1.0, 2.0, 3.0), 3.0);
+ assert_eq!(bezier_point(0.5, 0.0, 0.0, 1.0, 1.0), 0.5);
+ }
+
+ #[test]
+ fn default_handles_split_span_into_thirds() {
+ let left = key(0, 10.0);
+ let right = key(90, 40.0);
+ let right_handle = default_right_handle(&left, &right);
+ let left_handle = default_left_handle(&left, &right);
+ assert_eq!(right_handle.dt, 30.0);
+ assert_eq!(right_handle.dv, 10.0);
+ assert_eq!(left_handle.dt, -30.0);
+ assert_eq!(left_handle.dv, -10.0);
+ }
+
+ #[test]
+ fn solve_progress_is_linear_for_diagonal_control_points() {
+ let left = key(0, 0.0);
+ let right = key(100, 100.0);
+ let progress = solve_bezier_progress_for_time(MediaTime::from_ticks(50), &left, &right);
+ assert!((progress - 0.5).abs() < 1e-6);
+ let progress = solve_bezier_progress_for_time(MediaTime::from_ticks(25), &left, &right);
+ assert!((progress - 0.25).abs() < 1e-6);
+ }
+
+ #[test]
+ fn solve_progress_respects_flat_tangents() {
+ let mut left = key(0, 0.0);
+ left.right_handle = Some(CurveHandle {
+ dt: MediaTime::from_ticks(0),
+ dv: 0.0,
+ });
+ let mut right = key(100, 100.0);
+ right.left_handle = Some(CurveHandle {
+ dt: MediaTime::from_ticks(0),
+ dv: 0.0,
+ });
+ let early = solve_bezier_progress_for_time(MediaTime::from_ticks(25), &left, &right);
+ let late = solve_bezier_progress_for_time(MediaTime::from_ticks(75), &left, &right);
+ assert!(early < 0.25);
+ assert!(late > 0.75);
+ }
+}
diff --git a/rust/crates/animation/src/channel_data.rs b/rust/crates/animation/src/channel_data.rs
new file mode 100644
index 00000000..a05396e5
--- /dev/null
+++ b/rust/crates/animation/src/channel_data.rs
@@ -0,0 +1,92 @@
+use scene::{AnimationChannel, ChannelData, CompositeChannelData};
+
+const LEGACY_ANIMATION_STORAGE_KEYS: [&str; 2] = ["bindings", "channels"];
+
+pub fn is_animation_storage_key(key: &str) -> bool {
+ !LEGACY_ANIMATION_STORAGE_KEYS.contains(&key)
+}
+
+fn component_order(key: &str) -> usize {
+ match key {
+ "value" => 0,
+ "r" => 1,
+ "g" => 2,
+ "b" => 3,
+ "a" => 4,
+ _ => 5,
+ }
+}
+
+pub fn ordered_composite_entries(data: &CompositeChannelData) -> Vec<(&String, &AnimationChannel)> {
+ let mut entries: Vec<(&String, &AnimationChannel)> = data.iter().collect();
+ entries.sort_by_key(|(key, _)| component_order(key));
+ entries
+}
+
+pub fn get_channels_from_data(data: Option<&ChannelData>) -> Vec<&AnimationChannel> {
+ match data {
+ Some(ChannelData::Single(channel)) => vec![channel],
+ Some(ChannelData::Composite(components)) => ordered_composite_entries(components)
+ .into_iter()
+ .map(|(_, channel)| channel)
+ .collect(),
+ None => Vec::new(),
+ }
+}
+
+pub fn get_channel_entries_from_data(data: Option<&ChannelData>) -> Vec<(String, &AnimationChannel)> {
+ match data {
+ Some(ChannelData::Single(channel)) => vec![("value".to_string(), channel)],
+ Some(ChannelData::Composite(components)) => ordered_composite_entries(components)
+ .into_iter()
+ .map(|(key, channel)| (key.clone(), channel))
+ .collect(),
+ None => Vec::new(),
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use scene::{AnimationChannel, DiscreteChannel, ScalarChannel};
+ use std::collections::BTreeMap;
+
+ fn scalar_channel() -> AnimationChannel {
+ AnimationChannel::Scalar(ScalarChannel {
+ keys: Vec::new(),
+ extrapolation: None,
+ })
+ }
+
+ #[test]
+ fn leaf_data_yields_value_entry() {
+ let data = ChannelData::Single(scalar_channel());
+ let entries = get_channel_entries_from_data(Some(&data));
+ assert_eq!(entries.len(), 1);
+ assert_eq!(entries[0].0, "value");
+ }
+
+ #[test]
+ fn composite_entries_follow_canonical_component_order() {
+ let mut components = BTreeMap::new();
+ components.insert("a".to_string(), scalar_channel());
+ components.insert("b".to_string(), scalar_channel());
+ components.insert("g".to_string(), scalar_channel());
+ components.insert("r".to_string(), scalar_channel());
+ components.insert(
+ "mode".to_string(),
+ AnimationChannel::Discrete(DiscreteChannel { keys: Vec::new() }),
+ );
+ let data = ChannelData::Composite(components);
+ let entries = get_channel_entries_from_data(Some(&data));
+ let keys: Vec<&str> = entries.iter().map(|(key, _)| key.as_str()).collect();
+ assert_eq!(keys, ["r", "g", "b", "a", "mode"]);
+ }
+
+ #[test]
+ fn legacy_storage_keys_are_rejected() {
+ assert!(!is_animation_storage_key("bindings"));
+ assert!(!is_animation_storage_key("channels"));
+ assert!(is_animation_storage_key("opacity"));
+ }
+}
diff --git a/rust/crates/animation/src/channel_layout.rs b/rust/crates/animation/src/channel_layout.rs
new file mode 100644
index 00000000..59bbc3f6
--- /dev/null
+++ b/rust/crates/animation/src/channel_layout.rs
@@ -0,0 +1,124 @@
+use scene::ParamValue;
+use serde::{Deserialize, Serialize};
+use std::collections::BTreeMap;
+
+use crate::color::{LinearRgba, parse_color_to_linear_rgba};
+use crate::types::AnimationInterpolation;
+
+#[derive(Serialize, Deserialize, Clone, Copy, Debug, Eq, PartialEq)]
+#[serde(rename_all = "camelCase")]
+pub enum ChannelValueKind {
+ Scalar,
+ Discrete,
+}
+
+#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
+#[serde(rename_all = "camelCase")]
+pub struct ChannelComponentDefinition {
+ pub key: String,
+ pub value_kind: ChannelValueKind,
+ pub default_interpolation: AnimationInterpolation,
+}
+
+#[derive(Clone, Debug, PartialEq)]
+pub enum ChannelLayout {
+ Leaf(ChannelComponentDefinition),
+ Composite(Vec<ChannelComponentDefinition>),
+}
+
+impl ChannelLayout {
+ pub fn components(&self) -> &[ChannelComponentDefinition] {
+ match self {
+ ChannelLayout::Leaf(component) => std::slice::from_ref(component),
+ ChannelLayout::Composite(components) => components,
+ }
+ }
+
+ pub fn decompose(&self, value: &ParamValue) -> Option<BTreeMap<String, ParamValue>> {
+ match self {
+ ChannelLayout::Leaf(component) => {
+ let mut values = BTreeMap::new();
+ values.insert(component.key.clone(), value.clone());
+ Some(values)
+ }
+ ChannelLayout::Composite(_) => {
+ let ParamValue::String(color) = value else {
+ return None;
+ };
+ let LinearRgba { r, g, b, a } = parse_color_to_linear_rgba(color)?;
+ let mut values = BTreeMap::new();
+ values.insert("r".to_string(), ParamValue::Number(r));
+ values.insert("g".to_string(), ParamValue::Number(g));
+ values.insert("b".to_string(), ParamValue::Number(b));
+ values.insert("a".to_string(), ParamValue::Number(a));
+ Some(values)
+ }
+ }
+ }
+}
+
+pub fn number_channel_layout() -> ChannelLayout {
+ ChannelLayout::Leaf(ChannelComponentDefinition {
+ key: "value".to_string(),
+ value_kind: ChannelValueKind::Scalar,
+ default_interpolation: AnimationInterpolation::Linear,
+ })
+}
+
+pub fn boolean_channel_layout() -> ChannelLayout {
+ ChannelLayout::Leaf(ChannelComponentDefinition {
+ key: "value".to_string(),
+ value_kind: ChannelValueKind::Discrete,
+ default_interpolation: AnimationInterpolation::Hold,
+ })
+}
+
+pub fn string_channel_layout() -> ChannelLayout {
+ ChannelLayout::Leaf(ChannelComponentDefinition {
+ key: "value".to_string(),
+ value_kind: ChannelValueKind::Discrete,
+ default_interpolation: AnimationInterpolation::Hold,
+ })
+}
+
+pub fn color_channel_layout() -> ChannelLayout {
+ let component = |key: &str| ChannelComponentDefinition {
+ key: key.to_string(),
+ value_kind: ChannelValueKind::Scalar,
+ default_interpolation: AnimationInterpolation::Linear,
+ };
+ ChannelLayout::Composite(vec![
+ component("r"),
+ component("g"),
+ component("b"),
+ component("a"),
+ ])
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn leaf_decompose_wraps_value_under_component_key() {
+ let layout = number_channel_layout();
+ let values = layout.decompose(&ParamValue::Number(3.0)).unwrap();
+ assert_eq!(values.get("value"), Some(&ParamValue::Number(3.0)));
+ }
+
+ #[test]
+ fn color_decompose_parses_hex_into_linear_components() {
+ let layout = color_channel_layout();
+ let values = layout
+ .decompose(&ParamValue::String("#ff0000".to_string()))
+ .unwrap();
+ assert_eq!(values.get("r"), Some(&ParamValue::Number(1.0)));
+ assert_eq!(values.get("g"), Some(&ParamValue::Number(0.0)));
+ assert_eq!(values.get("a"), Some(&ParamValue::Number(1.0)));
+ assert!(
+ layout
+ .decompose(&ParamValue::Number(1.0))
+ .is_none()
+ );
+ }
+}
diff --git a/rust/crates/animation/src/color.rs b/rust/crates/animation/src/color.rs
new file mode 100644
index 00000000..03f409a1
--- /dev/null
+++ b/rust/crates/animation/src/color.rs
@@ -0,0 +1,137 @@
+#[derive(Clone, Copy, Debug, PartialEq)]
+pub struct LinearRgba {
+ pub r: f64,
+ pub g: f64,
+ pub b: f64,
+ pub a: f64,
+}
+
+fn clamp_unit(value: f64) -> f64 {
+ value.max(0.0).min(1.0)
+}
+
+fn srgb_to_linear(value: f64) -> f64 {
+ if value <= 0.04045 {
+ value / 12.92
+ } else {
+ ((value + 0.055) / 1.055).powf(2.4)
+ }
+}
+
+fn linear_to_srgb(value: f64) -> f64 {
+ let clamped = clamp_unit(value);
+ if clamped <= 0.003_130_8 {
+ clamped * 12.92
+ } else {
+ 1.055 * clamped.powf(1.0 / 2.4) - 0.055
+ }
+}
+
+fn parse_hex_digit(digit: char) -> Option<f64> {
+ digit.to_digit(16).map(|value| value as f64 / 15.0)
+}
+
+fn parse_hex_pair(pair: &str) -> Option<f64> {
+ u8::from_str_radix(pair, 16)
+ .ok()
+ .map(|value| value as f64 / 255.0)
+}
+
+pub fn parse_color_to_linear_rgba(color: &str) -> Option<LinearRgba> {
+ let hex = color.strip_prefix('#')?;
+ let (r, g, b, a) = match hex.len() {
+ 3 | 4 => {
+ let mut digits = hex.chars();
+ let r = parse_hex_digit(digits.next()?)?;
+ let g = parse_hex_digit(digits.next()?)?;
+ let b = parse_hex_digit(digits.next()?)?;
+ let a = digits.next().map(parse_hex_digit).transpose()?.unwrap_or(1.0);
+ (r, g, b, a)
+ }
+ 6 | 8 => {
+ let r = parse_hex_pair(&hex[0..2])?;
+ let g = parse_hex_pair(&hex[2..4])?;
+ let b = parse_hex_pair(&hex[4..6])?;
+ let a = if hex.len() == 8 {
+ parse_hex_pair(&hex[6..8])?
+ } else {
+ 1.0
+ };
+ (r, g, b, a)
+ }
+ _ => return None,
+ };
+ Some(LinearRgba {
+ r: srgb_to_linear(r),
+ g: srgb_to_linear(g),
+ b: srgb_to_linear(b),
+ a: clamp_unit(a),
+ })
+}
+
+fn hex_component(value: f64) -> u8 {
+ (clamp_unit(value) * 255.0).round() as u8
+}
+
+pub fn format_linear_rgba(color: &LinearRgba) -> String {
+ let r = hex_component(linear_to_srgb(color.r));
+ let g = hex_component(linear_to_srgb(color.g));
+ let b = hex_component(linear_to_srgb(color.b));
+ let a = clamp_unit(color.a);
+ if a < 1.0 {
+ format!("#{:02x}{:02x}{:02x}{:02x}", r, g, b, hex_component(a))
+ } else {
+ format!("#{:02x}{:02x}{:02x}", r, g, b)
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn parses_hex_colors_to_linear() {
+ let red = parse_color_to_linear_rgba("#ff0000").unwrap();
+ assert_eq!(red.r, 1.0);
+ assert_eq!(red.g, 0.0);
+ assert_eq!(red.b, 0.0);
+ assert_eq!(red.a, 1.0);
+
+ let short = parse_color_to_linear_rgba("#fff").unwrap();
+ assert_eq!(short.r, 1.0);
+ assert_eq!(short.g, 1.0);
+
+ let with_alpha = parse_color_to_linear_rgba("#00000080").unwrap();
+ assert!((with_alpha.a - 128.0 / 255.0).abs() < 1e-9);
+
+ assert!(parse_color_to_linear_rgba("red").is_none());
+ assert!(parse_color_to_linear_rgba("#ff").is_none());
+ }
+
+ #[test]
+ fn formats_linear_colors_as_hex() {
+ let red = LinearRgba {
+ r: 1.0,
+ g: 0.0,
+ b: 0.0,
+ a: 1.0,
+ };
+ assert_eq!(format_linear_rgba(&red), "#ff0000");
+
+ let translucent = LinearRgba {
+ r: 0.0,
+ g: 0.0,
+ b: 0.0,
+ a: 0.5,
+ };
+ assert_eq!(format_linear_rgba(&translucent), "#00000080");
+ }
+
+ #[test]
+ fn hex_round_trips_through_linear_space() {
+ for color in ["#ffffff", "#000000", "#123456", "#abcdef"] {
+ let parsed = parse_color_to_linear_rgba(color).unwrap();
+ assert_eq!(format_linear_rgba(&parsed), color);
+ }
+ }
+}
diff --git a/rust/crates/animation/src/id.rs b/rust/crates/animation/src/id.rs
new file mode 100644
index 00000000..95288660
--- /dev/null
+++ b/rust/crates/animation/src/id.rs
@@ -0,0 +1,61 @@
+use std::cell::Cell;
+use std::time::{SystemTime, UNIX_EPOCH};
+
+thread_local! {
+ static RNG_STATE: Cell<u64> = const { Cell::new(0) };
+}
+
+fn next_u64() -> u64 {
+ RNG_STATE.with(|state| {
+ let mut value = state.get();
+ if value == 0 {
+ let nanos = SystemTime::now()
+ .duration_since(UNIX_EPOCH)
+ .map(|duration| duration.as_nanos() as u64)
+ .unwrap_or(0x9e37_79b9_7f4a_7c15);
+ value = nanos
+ ^ (std::process::id() as u64).rotate_left(32)
+ ^ 0x9e37_79b9_7f4a_7c15;
+ }
+ value ^= value << 13;
+ value ^= value >> 7;
+ value ^= value << 17;
+ state.set(value);
+ value
+ })
+}
+
+pub fn generate_uuid() -> String {
+ let mut bytes = [0u8; 16];
+ for chunk in bytes.chunks_mut(8) {
+ let random = next_u64().to_le_bytes();
+ chunk.copy_from_slice(&random[..chunk.len()]);
+ }
+ bytes[6] = (bytes[6] & 0x0f) | 0x40;
+ bytes[8] = (bytes[8] & 0x3f) | 0x80;
+ let hex: Vec<String> = bytes.iter().map(|byte| format!("{byte:02x}")).collect();
+ format!(
+ "{}-{}-{}-{}-{}",
+ hex[0..4].join(""),
+ hex[4..6].join(""),
+ hex[6..8].join(""),
+ hex[8..10].join(""),
+ hex[10..16].join("")
+ )
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn generates_uuid_shaped_unique_ids() {
+ let id = generate_uuid();
+ let parts: Vec<&str> = id.split('-').collect();
+ assert_eq!(parts.len(), 5);
+ assert_eq!(parts[0].len(), 8);
+ assert_eq!(parts[1].len(), 4);
+ assert_eq!(parts[4].len(), 12);
+ assert_ne!(generate_uuid(), generate_uuid());
+ }
+}
diff --git a/rust/crates/animation/src/interpolation.rs b/rust/crates/animation/src/interpolation.rs
new file mode 100644
index 00000000..85c0735d
--- /dev/null
+++ b/rust/crates/animation/src/interpolation.rs
@@ -0,0 +1,467 @@
+use scene::{
+ AnimationChannel, ChannelExtrapolationMode, CurveHandle, DiscreteChannel, DiscreteValue,
+ ParamValue, ScalarAnimationKey, ScalarChannel, ScalarSegmentType,
+};
+use time::MediaTime;
+
+use crate::bezier::{bezier_point, segment_handles, solve_bezier_progress_for_time};
+use crate::types::AnimationInterpolation;
+
+pub fn is_scalar_channel(channel: &AnimationChannel) -> bool {
+ matches!(channel, AnimationChannel::Scalar(_))
+}
+
+fn normalize_right_handle(
+ handle: Option<CurveHandle>,
+ left_key: &ScalarAnimationKey,
+ right_key: &ScalarAnimationKey,
+) -> Option<CurveHandle> {
+ let handle = handle?;
+ let span = (right_key.time - left_key.time).as_ticks().max(1);
+ Some(CurveHandle {
+ dt: MediaTime::from_ticks(handle.dt.as_ticks().clamp(0, span)),
+ dv: handle.dv,
+ })
+}
+
+fn normalize_left_handle(
+ handle: Option<CurveHandle>,
+ left_key: &ScalarAnimationKey,
+ right_key: &ScalarAnimationKey,
+) -> Option<CurveHandle> {
+ let handle = handle?;
+ let span = (right_key.time - left_key.time).as_ticks().max(1);
+ Some(CurveHandle {
+ dt: MediaTime::from_ticks(handle.dt.as_ticks().clamp(-span, 0)),
+ dv: handle.dv,
+ })
+}
+
+pub fn normalize_scalar_channel(channel: &ScalarChannel) -> ScalarChannel {
+ let mut sorted_keys = channel.keys.clone();
+ sorted_keys.sort_by_key(|key| key.time);
+
+ let mut next_keys = Vec::with_capacity(sorted_keys.len());
+ for (index, key) in sorted_keys.iter().enumerate() {
+ let previous_key = index.checked_sub(1).and_then(|i| sorted_keys.get(i));
+ let next_key = sorted_keys.get(index + 1);
+ let left_handle =
+ previous_key.and_then(|previous| normalize_left_handle(key.left_handle, previous, key));
+ let right_handle =
+ next_key.and_then(|next| normalize_right_handle(key.right_handle, key, next));
+ next_keys.push(ScalarAnimationKey {
+ left_handle,
+ right_handle,
+ ..key.clone()
+ });
+ }
+
+ ScalarChannel {
+ keys: next_keys,
+ extrapolation: channel.extrapolation.clone(),
+ }
+}
+
+pub fn normalize_discrete_channel(channel: &DiscreteChannel) -> DiscreteChannel {
+ let mut keys = channel.keys.clone();
+ keys.sort_by_key(|key| key.time);
+ DiscreteChannel { keys }
+}
+
+pub fn normalize_channel(channel: &AnimationChannel) -> AnimationChannel {
+ match channel {
+ AnimationChannel::Scalar(scalar) => {
+ AnimationChannel::Scalar(normalize_scalar_channel(scalar))
+ }
+ AnimationChannel::Discrete(discrete) => {
+ AnimationChannel::Discrete(normalize_discrete_channel(discrete))
+ }
+ }
+}
+
+pub fn scalar_segment_interpolation(segment: ScalarSegmentType) -> AnimationInterpolation {
+ match segment {
+ ScalarSegmentType::Step => AnimationInterpolation::Hold,
+ ScalarSegmentType::Linear => AnimationInterpolation::Linear,
+ ScalarSegmentType::Bezier => AnimationInterpolation::Bezier,
+ }
+}
+
+fn extrapolate_scalar_edge(
+ mode: ChannelExtrapolationMode,
+ edge_key: &ScalarAnimationKey,
+ neighbor_key: Option<&ScalarAnimationKey>,
+ time: f64,
+) -> f64 {
+ let Some(neighbor_key) = neighbor_key else {
+ return edge_key.value;
+ };
+ if mode == ChannelExtrapolationMode::Hold {
+ return edge_key.value;
+ }
+
+ let span = (neighbor_key.time - edge_key.time).as_ticks() as f64;
+ if span == 0.0 {
+ return edge_key.value;
+ }
+
+ edge_key.value
+ + ((time - edge_key.time.as_ticks() as f64) / span) * (neighbor_key.value - edge_key.value)
+}
+
+fn lerp_number(left_value: f64, right_value: f64, progress: f64) -> f64 {
+ left_value + (right_value - left_value) * progress
+}
+
+pub fn scalar_channel_value_at_time(
+ channel: Option<&ScalarChannel>,
+ time: MediaTime,
+ fallback_value: f64,
+) -> f64 {
+ let Some(channel) = channel.filter(|channel| !channel.keys.is_empty()) else {
+ return fallback_value;
+ };
+
+ let normalized = normalize_scalar_channel(channel);
+ let keys = &normalized.keys;
+ let first_key = &keys[0];
+ let last_key = &keys[keys.len() - 1];
+ let t = time.as_ticks() as f64;
+
+ if time <= first_key.time {
+ if time < first_key.time {
+ return extrapolate_scalar_edge(
+ normalized
+ .extrapolation
+ .as_ref()
+ .map(|e| e.before)
+ .unwrap_or(ChannelExtrapolationMode::Hold),
+ first_key,
+ keys.get(1),
+ t,
+ );
+ }
+ return first_key.value;
+ }
+
+ if time >= last_key.time {
+ if time > last_key.time {
+ return extrapolate_scalar_edge(
+ normalized
+ .extrapolation
+ .as_ref()
+ .map(|e| e.after)
+ .unwrap_or(ChannelExtrapolationMode::Hold),
+ last_key,
+ keys.len().checked_sub(2).and_then(|index| keys.get(index)),
+ t,
+ );
+ }
+ return last_key.value;
+ }
+
+ for key_index in 0..keys.len() - 1 {
+ let left_key = &keys[key_index];
+ let right_key = &keys[key_index + 1];
+ if time == right_key.time {
+ return right_key.value;
+ }
+ if time < left_key.time || time > right_key.time {
+ continue;
+ }
+
+ if left_key.segment_to_next == ScalarSegmentType::Step {
+ return left_key.value;
+ }
+
+ let span = (right_key.time - left_key.time).as_ticks() as f64;
+ if span == 0.0 {
+ return right_key.value;
+ }
+
+ let progress = ((t - left_key.time.as_ticks() as f64) / span).clamp(0.0, 1.0);
+ if left_key.segment_to_next == ScalarSegmentType::Linear {
+ return lerp_number(left_key.value, right_key.value, progress);
+ }
+
+ let curve_progress = solve_bezier_progress_for_time(time, left_key, right_key);
+ let (right_handle, left_handle) = segment_handles(left_key, right_key);
+ return bezier_point(
+ curve_progress,
+ left_key.value,
+ left_key.value + right_handle.dv,
+ right_key.value + left_handle.dv,
+ right_key.value,
+ );
+ }
+
+ last_key.value
+}
+
+pub fn discrete_channel_value_at_time(
+ channel: Option<&DiscreteChannel>,
+ time: MediaTime,
+ fallback_value: DiscreteValue,
+) -> DiscreteValue {
+ let Some(channel) = channel.filter(|channel| !channel.keys.is_empty()) else {
+ return fallback_value;
+ };
+
+ let normalized = normalize_discrete_channel(channel);
+ let mut current_value = fallback_value;
+ for key in &normalized.keys {
+ if time < key.time {
+ break;
+ }
+ current_value = key.value.clone();
+ }
+ current_value
+}
+
+pub fn channel_value_at_time(
+ channel: Option<&AnimationChannel>,
+ time: MediaTime,
+ fallback_value: &ParamValue,
+) -> ParamValue {
+ let Some(channel) = channel.filter(|channel| !channel.keys.is_empty()) else {
+ return fallback_value.clone();
+ };
+
+ if let ParamValue::Number(number_fallback) = fallback_value {
+ return match channel {
+ AnimationChannel::Scalar(scalar) => ParamValue::Number(scalar_channel_value_at_time(
+ Some(scalar),
+ time,
+ *number_fallback,
+ )),
+ AnimationChannel::Discrete(_) => fallback_value.clone(),
+ };
+ }
+
+ match channel {
+ AnimationChannel::Discrete(discrete) => {
+ let discrete_fallback = match fallback_value {
+ ParamValue::Bool(value) => DiscreteValue::Bool(*value),
+ ParamValue::String(value) => DiscreteValue::String(value.clone()),
+ ParamValue::Number(_) => return fallback_value.clone(),
+ };
+ discrete_channel_value_at_time(Some(discrete), time, discrete_fallback).into()
+ }
+ AnimationChannel::Scalar(_) => fallback_value.clone(),
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use scene::{ChannelExtrapolation, TangentMode};
+
+ fn scalar_key(id: &str, time: i64, value: f64, segment: ScalarSegmentType) -> ScalarAnimationKey {
+ ScalarAnimationKey {
+ id: id.to_string(),
+ time: MediaTime::from_ticks(time),
+ value,
+ left_handle: None,
+ right_handle: None,
+ segment_to_next: segment,
+ tangent_mode: TangentMode::Flat,
+ }
+ }
+
+ fn channel(keys: Vec<ScalarAnimationKey>) -> ScalarChannel {
+ ScalarChannel {
+ keys,
+ extrapolation: None,
+ }
+ }
+
+ fn at(time: i64) -> MediaTime {
+ MediaTime::from_ticks(time)
+ }
+
+ #[test]
+ fn empty_channel_returns_fallback() {
+ assert_eq!(scalar_channel_value_at_time(None, at(10), 7.0), 7.0);
+ assert_eq!(
+ scalar_channel_value_at_time(Some(&channel(Vec::new())), at(10), 7.0),
+ 7.0
+ );
+ }
+
+ #[test]
+ fn linear_segment_interpolates() {
+ let channel = channel(vec![
+ scalar_key("a", 0, 0.0, ScalarSegmentType::Linear),
+ scalar_key("b", 100, 10.0, ScalarSegmentType::Linear),
+ ]);
+ assert_eq!(scalar_channel_value_at_time(Some(&channel), at(50), 0.0), 5.0);
+ assert_eq!(scalar_channel_value_at_time(Some(&channel), at(0), 0.0), 0.0);
+ assert_eq!(scalar_channel_value_at_time(Some(&channel), at(100), 0.0), 10.0);
+ }
+
+ #[test]
+ fn step_segment_holds_left_value() {
+ let channel = channel(vec![
+ scalar_key("a", 0, 1.0, ScalarSegmentType::Step),
+ scalar_key("b", 100, 9.0, ScalarSegmentType::Linear),
+ ]);
+ assert_eq!(scalar_channel_value_at_time(Some(&channel), at(99), 0.0), 1.0);
+ assert_eq!(scalar_channel_value_at_time(Some(&channel), at(100), 0.0), 9.0);
+ }
+
+ #[test]
+ fn unsorted_keys_are_sorted_before_sampling() {
+ let channel = channel(vec![
+ scalar_key("b", 100, 10.0, ScalarSegmentType::Linear),
+ scalar_key("a", 0, 0.0, ScalarSegmentType::Linear),
+ ]);
+ assert_eq!(scalar_channel_value_at_time(Some(&channel), at(50), 0.0), 5.0);
+ }
+
+ #[test]
+ fn hold_extrapolation_is_the_default() {
+ let channel = channel(vec![
+ scalar_key("a", 10, 1.0, ScalarSegmentType::Linear),
+ scalar_key("b", 100, 10.0, ScalarSegmentType::Linear),
+ ]);
+ assert_eq!(scalar_channel_value_at_time(Some(&channel), at(0), 0.0), 1.0);
+ assert_eq!(scalar_channel_value_at_time(Some(&channel), at(200), 0.0), 10.0);
+ }
+
+ #[test]
+ fn linear_extrapolation_extends_edge_segments() {
+ let mut channel = channel(vec![
+ scalar_key("a", 0, 0.0, ScalarSegmentType::Linear),
+ scalar_key("b", 100, 10.0, ScalarSegmentType::Linear),
+ ]);
+ channel.extrapolation = Some(ChannelExtrapolation {
+ before: ChannelExtrapolationMode::Linear,
+ after: ChannelExtrapolationMode::Linear,
+ });
+ assert_eq!(scalar_channel_value_at_time(Some(&channel), at(-50), 0.0), -5.0);
+ assert_eq!(scalar_channel_value_at_time(Some(&channel), at(150), 0.0), 15.0);
+ }
+
+ #[test]
+ fn linear_extrapolation_holds_with_single_key() {
+ let mut channel = channel(vec![scalar_key("a", 10, 3.0, ScalarSegmentType::Linear)]);
+ channel.extrapolation = Some(ChannelExtrapolation {
+ before: ChannelExtrapolationMode::Linear,
+ after: ChannelExtrapolationMode::Linear,
+ });
+ assert_eq!(scalar_channel_value_at_time(Some(&channel), at(0), 0.0), 3.0);
+ assert_eq!(scalar_channel_value_at_time(Some(&channel), at(50), 0.0), 3.0);
+ }
+
+ #[test]
+ fn bezier_segment_with_default_handles_matches_diagonal() {
+ let channel = channel(vec![
+ scalar_key("a", 0, 0.0, ScalarSegmentType::Bezier),
+ scalar_key("b", 100, 100.0, ScalarSegmentType::Bezier),
+ ]);
+ let value = scalar_channel_value_at_time(Some(&channel), at(50), 0.0);
+ assert!((value - 50.0).abs() < 1e-6);
+ }
+
+ #[test]
+ fn bezier_segment_with_flat_handles_eases() {
+ let mut left = scalar_key("a", 0, 0.0, ScalarSegmentType::Bezier);
+ left.right_handle = Some(CurveHandle {
+ dt: at(50),
+ dv: 0.0,
+ });
+ let mut right = scalar_key("b", 100, 100.0, ScalarSegmentType::Bezier);
+ right.left_handle = Some(CurveHandle {
+ dt: at(-50),
+ dv: 0.0,
+ });
+ let channel = channel(vec![left, right]);
+ let quarter = scalar_channel_value_at_time(Some(&channel), at(25), 0.0);
+ let mid = scalar_channel_value_at_time(Some(&channel), at(50), 0.0);
+ assert!(quarter < 25.0);
+ assert!((mid - 50.0).abs() < 1e-6);
+ }
+
+ #[test]
+ fn handles_are_clamped_to_segment_span() {
+ let mut left = scalar_key("a", 0, 0.0, ScalarSegmentType::Bezier);
+ left.right_handle = Some(CurveHandle {
+ dt: at(500),
+ dv: 1.0,
+ });
+ let mut right = scalar_key("b", 100, 100.0, ScalarSegmentType::Bezier);
+ right.left_handle = Some(CurveHandle {
+ dt: at(50),
+ dv: 1.0,
+ });
+ let normalized = normalize_scalar_channel(&channel(vec![left, right]));
+ assert_eq!(normalized.keys[0].right_handle.unwrap().dt, at(100));
+ assert_eq!(normalized.keys[1].left_handle.unwrap().dt, at(0));
+ }
+
+ #[test]
+ fn edge_keys_drop_outer_handles() {
+ let mut left = scalar_key("a", 0, 0.0, ScalarSegmentType::Bezier);
+ left.left_handle = Some(CurveHandle {
+ dt: at(-10),
+ dv: 1.0,
+ });
+ let mut right = scalar_key("b", 100, 100.0, ScalarSegmentType::Bezier);
+ right.right_handle = Some(CurveHandle {
+ dt: at(10),
+ dv: 1.0,
+ });
+ let normalized = normalize_scalar_channel(&channel(vec![left, right]));
+ assert_eq!(normalized.keys[0].left_handle, None);
+ assert_eq!(normalized.keys[1].right_handle, None);
+ }
+
+ #[test]
+ fn discrete_channel_returns_last_key_at_or_before_time() {
+ let channel = DiscreteChannel {
+ keys: vec![
+ DiscreteAnimationKey {
+ id: "a".to_string(),
+ time: at(0),
+ value: DiscreteValue::String("x".to_string()),
+ },
+ DiscreteAnimationKey {
+ id: "b".to_string(),
+ time: at(100),
+ value: DiscreteValue::Bool(true),
+ },
+ ],
+ };
+ let fallback = DiscreteValue::Bool(false);
+ assert_eq!(
+ discrete_channel_value_at_time(Some(&channel), at(-1), fallback.clone()),
+ fallback
+ );
+ assert_eq!(
+ discrete_channel_value_at_time(Some(&channel), at(50), fallback.clone()),
+ DiscreteValue::String("x".to_string())
+ );
+ assert_eq!(
+ discrete_channel_value_at_time(Some(&channel), at(100), fallback.clone()),
+ DiscreteValue::Bool(true)
+ );
+ }
+
+ #[test]
+ fn channel_value_at_time_dispatches_on_fallback_type() {
+ let scalar = AnimationChannel::Scalar(channel(vec![scalar_key(
+ "a",
+ 0,
+ 2.0,
+ ScalarSegmentType::Linear,
+ )]));
+ assert_eq!(
+ channel_value_at_time(Some(&scalar), at(0), &ParamValue::Number(0.0)),
+ ParamValue::Number(2.0)
+ );
+ assert_eq!(
+ channel_value_at_time(Some(&scalar), at(0), &ParamValue::Bool(true)),
+ ParamValue::Bool(true)
+ );
+ }
+}
diff --git a/rust/crates/animation/src/keyframes.rs b/rust/crates/animation/src/keyframes.rs
new file mode 100644
index 00000000..0ce938f9
--- /dev/null
+++ b/rust/crates/animation/src/keyframes.rs
@@ -0,0 +1,1150 @@
+use std::collections::{BTreeMap, HashMap};
+
+use scene::{
+ AnimationChannel, ChannelData, DiscreteAnimationKey, DiscreteChannel, DiscreteValue,
+ ElementAnimations, ParamValue, ScalarAnimationKey, ScalarChannel, ScalarSegmentType,
+ TangentMode,
+};
+use time::MediaTime;
+
+use crate::bezier::{segment_handles, solve_bezier_progress_for_time};
+use crate::channel_data::{
+ get_channel_entries_from_data, get_channels_from_data, is_animation_storage_key,
+};
+use crate::channel_layout::{ChannelLayout, ChannelValueKind};
+use crate::id::generate_uuid;
+use crate::interpolation::{
+ channel_value_at_time, is_scalar_channel, normalize_channel, normalize_discrete_channel,
+ normalize_scalar_channel, scalar_segment_interpolation,
+};
+use crate::types::{AnimationInterpolation, ScalarCurveKeyframePatch};
+
+fn round_media_time(time: f64) -> MediaTime {
+ MediaTime::from_ticks(time.round() as i64)
+}
+
+fn has_channel_keys(channel: Option<&AnimationChannel>) -> bool {
+ channel.is_some_and(|channel| !channel.keys().is_empty())
+}
+
+trait ChannelKeys {
+ fn keys(&self) -> usize;
+}
+
+impl ChannelKeys for AnimationChannel {
+ fn keys(&self) -> usize {
+ match self {
+ AnimationChannel::Scalar(scalar) => scalar.keys.len(),
+ AnimationChannel::Discrete(discrete) => discrete.keys.len(),
+ }
+ }
+}
+
+fn has_channel_data(data: Option<&ChannelData>) -> bool {
+ get_channels_from_data(data)
+ .iter()
+ .any(|channel| has_channel_keys(Some(channel)))
+}
+
+pub fn to_animation(animations: ElementAnimations) -> Option<ElementAnimations> {
+ let next: ElementAnimations = animations
+ .into_iter()
+ .filter(|(key, data)| is_animation_storage_key(key) && has_channel_data(Some(data)))
+ .collect();
+ if next.is_empty() { None } else { Some(next) }
+}
+
+fn get_channel_from_data<'a>(
+ data: Option<&'a ChannelData>,
+ component_key: &str,
+) -> Option<&'a AnimationChannel> {
+ match data {
+ Some(ChannelData::Single(channel)) => {
+ if component_key == "value" { Some(channel) } else { None }
+ }
+ Some(ChannelData::Composite(components)) => components.get(component_key),
+ None => None,
+ }
+}
+
+fn get_primary_component_key(channel_layout: &ChannelLayout) -> String {
+ channel_layout
+ .components()
+ .first()
+ .map(|component| component.key.clone())
+ .unwrap_or_else(|| "value".to_string())
+}
+
+fn get_primary_channel_from_data<'a>(
+ data: Option<&'a ChannelData>,
+ channel_layout: &ChannelLayout,
+) -> Option<&'a AnimationChannel> {
+ get_channel_from_data(data, &get_primary_component_key(channel_layout))
+}
+
+fn set_channel_in_data(
+ data: Option<ChannelData>,
+ component_key: &str,
+ channel: Option<AnimationChannel>,
+) -> Option<ChannelData> {
+ if component_key == "value" {
+ return channel.map(ChannelData::Single);
+ }
+
+ let mut components = match data {
+ Some(ChannelData::Composite(components)) => components,
+ _ => BTreeMap::new(),
+ };
+ match channel {
+ Some(channel) if has_channel_keys(Some(&channel)) => {
+ components.insert(component_key.to_string(), channel);
+ }
+ _ => {
+ components.remove(component_key);
+ }
+ }
+ if components.is_empty() {
+ None
+ } else {
+ Some(ChannelData::Composite(components))
+ }
+}
+
+fn scalar_segment_type(interpolation: AnimationInterpolation) -> ScalarSegmentType {
+ match interpolation {
+ AnimationInterpolation::Hold => ScalarSegmentType::Step,
+ AnimationInterpolation::Bezier => ScalarSegmentType::Bezier,
+ AnimationInterpolation::Linear => ScalarSegmentType::Linear,
+ }
+}
+
+fn create_scalar_key(
+ id: String,
+ time: MediaTime,
+ value: f64,
+ interpolation: Option<AnimationInterpolation>,
+ previous_key: Option<&ScalarAnimationKey>,
+) -> ScalarAnimationKey {
+ ScalarAnimationKey {
+ id,
+ time,
+ value,
+ left_handle: previous_key.and_then(|key| key.left_handle),
+ right_handle: previous_key.and_then(|key| key.right_handle),
+ segment_to_next: previous_key.map(|key| key.segment_to_next).unwrap_or_else(|| {
+ scalar_segment_type(interpolation.unwrap_or(AnimationInterpolation::Linear))
+ }),
+ tangent_mode: previous_key
+ .map(|key| key.tangent_mode)
+ .unwrap_or(TangentMode::Flat),
+ }
+}
+
+fn channel_key_ids_and_times(channel: &AnimationChannel) -> Vec<(String, MediaTime)> {
+ match channel {
+ AnimationChannel::Scalar(scalar) => scalar
+ .keys
+ .iter()
+ .map(|key| (key.id.clone(), key.time))
+ .collect(),
+ AnimationChannel::Discrete(discrete) => discrete
+ .keys
+ .iter()
+ .map(|key| (key.id.clone(), key.time))
+ .collect(),
+ }
+}
+
+fn get_target_key_metadata(
+ channel: Option<&AnimationChannel>,
+ time: MediaTime,
+ keyframe_id: Option<&str>,
+) -> (String, MediaTime) {
+ let normalized = channel.map(normalize_channel);
+ let keys = normalized
+ .as_ref()
+ .map(channel_key_ids_and_times)
+ .unwrap_or_default();
+
+ if let Some(id) = keyframe_id {
+ if let Some((found_id, _)) = keys.iter().find(|(key_id, _)| key_id == id) {
+ return (found_id.clone(), time);
+ }
+ }
+
+ if let Some((found_id, found_time)) = keys.iter().find(|(_, key_time)| *key_time == time) {
+ return (found_id.clone(), *found_time);
+ }
+
+ (
+ keyframe_id.map(str::to_string).unwrap_or_else(generate_uuid),
+ time,
+ )
+}
+
+pub fn upsert_discrete_channel_key(
+ channel: Option<&DiscreteChannel>,
+ time: MediaTime,
+ value: DiscreteValue,
+ keyframe_id: Option<&str>,
+) -> DiscreteChannel {
+ let normalized = normalize_discrete_channel(&channel.cloned().unwrap_or(DiscreteChannel {
+ keys: Vec::new(),
+ }));
+ let mut keys = normalized.keys;
+
+ if let Some(id) = keyframe_id {
+ if let Some(index) = keys.iter().position(|key| key.id == id) {
+ keys[index] = DiscreteAnimationKey {
+ id: keys[index].id.clone(),
+ time,
+ value,
+ };
+ return normalize_discrete_channel(&DiscreteChannel { keys });
+ }
+ }
+
+ if let Some(index) = keys.iter().position(|key| key.time == time) {
+ keys[index] = DiscreteAnimationKey {
+ id: keys[index].id.clone(),
+ time: keys[index].time,
+ value,
+ };
+ return normalize_discrete_channel(&DiscreteChannel { keys });
+ }
+
+ keys.push(DiscreteAnimationKey {
+ id: keyframe_id.map(str::to_string).unwrap_or_else(generate_uuid),
+ time,
+ value,
+ });
+ normalize_discrete_channel(&DiscreteChannel { keys })
+}
+
+fn replace_scalar_key_at(
+ keys: &mut [ScalarAnimationKey],
+ index: usize,
+ time: MediaTime,
+ value: f64,
+ interpolation: Option<AnimationInterpolation>,
+ keep_existing_time: bool,
+) {
+ let mut previous_key = keys[index].clone();
+ if let Some(interpolation) = interpolation {
+ previous_key.segment_to_next = scalar_segment_type(interpolation);
+ }
+ let key_time = if keep_existing_time {
+ previous_key.time
+ } else {
+ time
+ };
+ keys[index] = create_scalar_key(
+ previous_key.id.clone(),
+ key_time,
+ value,
+ interpolation,
+ Some(&previous_key),
+ );
+}
+
+pub fn upsert_scalar_channel_key(
+ channel: Option<&ScalarChannel>,
+ time: MediaTime,
+ value: f64,
+ interpolation: Option<AnimationInterpolation>,
+ default_interpolation: Option<AnimationInterpolation>,
+ keyframe_id: Option<&str>,
+) -> ScalarChannel {
+ let normalized = normalize_scalar_channel(&channel.cloned().unwrap_or(ScalarChannel {
+ keys: Vec::new(),
+ extrapolation: None,
+ }));
+ let mut keys = normalized.keys;
+
+ if let Some(id) = keyframe_id {
+ if let Some(index) = keys.iter().position(|key| key.id == id) {
+ replace_scalar_key_at(&mut keys, index, time, value, interpolation, false);
+ return normalize_scalar_channel(&ScalarChannel {
+ keys,
+ extrapolation: normalized.extrapolation,
+ });
+ }
+ }
+
+ if let Some(index) = keys.iter().position(|key| key.time == time) {
+ replace_scalar_key_at(&mut keys, index, time, value, interpolation, true);
+ return normalize_scalar_channel(&ScalarChannel {
+ keys,
+ extrapolation: normalized.extrapolation,
+ });
+ }
+
+ keys.push(create_scalar_key(
+ keyframe_id.map(str::to_string).unwrap_or_else(generate_uuid),
+ time,
+ value,
+ interpolation.or(default_interpolation),
+ None,
+ ));
+ normalize_scalar_channel(&ScalarChannel {
+ keys,
+ extrapolation: normalized.extrapolation,
+ })
+}
+
+pub fn get_channel<'a>(
+ animations: Option<&'a ElementAnimations>,
+ property_path: &str,
+) -> Option<&'a AnimationChannel> {
+ let data = animations?.get(property_path);
+ match data {
+ ChannelData::Single(channel) => Some(channel),
+ ChannelData::Composite(_) => get_channels_from_data(Some(data)).into_iter().next(),
+ }
+}
+
+pub fn upsert_path_keyframe(
+ animations: Option<&ElementAnimations>,
+ property_path: &str,
+ time: MediaTime,
+ value: &ParamValue,
+ interpolation: Option<AnimationInterpolation>,
+ keyframe_id: Option<&str>,
+ channel_layout: &ChannelLayout,
+ coerce_value: impl Fn(&ParamValue) -> Option<ParamValue>,
+) -> Option<ElementAnimations> {
+ let coerced_value = coerce_value(value)?;
+
+ let mut next_animations = animations.cloned().unwrap_or_default();
+ let current_data = animations.and_then(|a| a.get(property_path)).cloned();
+ let primary_channel = get_primary_channel_from_data(current_data.as_ref(), channel_layout);
+ let (target_id, target_time) = get_target_key_metadata(primary_channel, time, keyframe_id);
+ let component_values = channel_layout.decompose(&coerced_value)?;
+
+ let mut next_data = current_data.clone();
+ for component in channel_layout.components() {
+ let component_key = component.key.as_str();
+ let Some(next_value) = component_values.get(component_key) else {
+ continue;
+ };
+ let current_channel = get_channel_from_data(current_data.as_ref(), component_key);
+
+ match component.value_kind {
+ ChannelValueKind::Discrete => {
+ let discrete_value = match next_value {
+ ParamValue::String(value) => DiscreteValue::String(value.clone()),
+ ParamValue::Bool(value) => DiscreteValue::Bool(*value),
+ ParamValue::Number(_) => continue,
+ };
+ let discrete_channel = match current_channel {
+ Some(AnimationChannel::Discrete(discrete)) => Some(discrete),
+ _ => None,
+ };
+ let next_channel = upsert_discrete_channel_key(
+ discrete_channel,
+ target_time,
+ discrete_value,
+ Some(&target_id),
+ );
+ next_data = set_channel_in_data(
+ next_data,
+ component_key,
+ Some(AnimationChannel::Discrete(next_channel)),
+ );
+ }
+ ChannelValueKind::Scalar => {
+ let ParamValue::Number(number_value) = next_value else {
+ continue;
+ };
+ let scalar_channel = match current_channel {
+ Some(AnimationChannel::Scalar(scalar)) => Some(scalar),
+ _ => None,
+ };
+ let next_channel = upsert_scalar_channel_key(
+ scalar_channel,
+ target_time,
+ *number_value,
+ interpolation,
+ Some(component.default_interpolation),
+ Some(&target_id),
+ );
+ next_data = set_channel_in_data(
+ next_data,
+ component_key,
+ Some(AnimationChannel::Scalar(next_channel)),
+ );
+ }
+ }
+ }
+
+ match next_data {
+ Some(data) => {
+ next_animations.insert(property_path.to_string(), data);
+ }
+ None => {
+ next_animations.remove(property_path);
+ }
+ }
+ to_animation(next_animations)
+}
+
+pub fn upsert_keyframe(
+ channel: Option<&AnimationChannel>,
+ time: MediaTime,
+ value: &ParamValue,
+ interpolation: Option<AnimationInterpolation>,
+ keyframe_id: Option<&str>,
+) -> Option<AnimationChannel> {
+ let channel = channel?;
+
+ match value {
+ ParamValue::String(value) => {
+ let discrete = match channel {
+ AnimationChannel::Discrete(discrete) => Some(discrete),
+ _ => None,
+ };
+ Some(AnimationChannel::Discrete(upsert_discrete_channel_key(
+ discrete,
+ time,
+ DiscreteValue::String(value.clone()),
+ keyframe_id,
+ )))
+ }
+ ParamValue::Bool(value) => {
+ let discrete = match channel {
+ AnimationChannel::Discrete(discrete) => Some(discrete),
+ _ => None,
+ };
+ Some(AnimationChannel::Discrete(upsert_discrete_channel_key(
+ discrete,
+ time,
+ DiscreteValue::Bool(*value),
+ keyframe_id,
+ )))
+ }
+ ParamValue::Number(value) => {
+ let scalar = match channel {
+ AnimationChannel::Scalar(scalar) => Some(scalar),
+ _ => None,
+ };
+ Some(AnimationChannel::Scalar(upsert_scalar_channel_key(
+ scalar,
+ time,
+ *value,
+ interpolation,
+ None,
+ keyframe_id,
+ )))
+ }
+ }
+}
+
+pub fn remove_keyframe(
+ channel: Option<&AnimationChannel>,
+ keyframe_id: &str,
+) -> Option<AnimationChannel> {
+ let channel = channel?;
+
+ match channel {
+ AnimationChannel::Scalar(scalar) => {
+ let keys: Vec<ScalarAnimationKey> = scalar
+ .keys
+ .iter()
+ .filter(|key| key.id != keyframe_id)
+ .cloned()
+ .collect();
+ if keys.is_empty() {
+ return None;
+ }
+ Some(AnimationChannel::Scalar(normalize_scalar_channel(
+ &ScalarChannel {
+ keys,
+ extrapolation: scalar.extrapolation.clone(),
+ },
+ )))
+ }
+ AnimationChannel::Discrete(discrete) => {
+ let keys: Vec<DiscreteAnimationKey> = discrete
+ .keys
+ .iter()
+ .filter(|key| key.id != keyframe_id)
+ .cloned()
+ .collect();
+ if keys.is_empty() {
+ return None;
+ }
+ Some(AnimationChannel::Discrete(normalize_discrete_channel(
+ &DiscreteChannel { keys },
+ )))
+ }
+ }
+}
+
+pub fn retime_keyframe(
+ channel: Option<&AnimationChannel>,
+ keyframe_id: &str,
+ time: MediaTime,
+) -> Option<AnimationChannel> {
+ let channel = channel?;
+
+ match channel {
+ AnimationChannel::Scalar(scalar) => {
+ let Some(index) = scalar.keys.iter().position(|key| key.id == keyframe_id) else {
+ return Some(channel.clone());
+ };
+ let mut keys = scalar.keys.clone();
+ keys[index].time = time;
+ Some(AnimationChannel::Scalar(normalize_scalar_channel(
+ &ScalarChannel {
+ keys,
+ extrapolation: scalar.extrapolation.clone(),
+ },
+ )))
+ }
+ AnimationChannel::Discrete(discrete) => {
+ let Some(index) = discrete.keys.iter().position(|key| key.id == keyframe_id) else {
+ return Some(channel.clone());
+ };
+ let mut keys = discrete.keys.clone();
+ keys[index].time = time;
+ Some(AnimationChannel::Discrete(normalize_discrete_channel(
+ &DiscreteChannel { keys },
+ )))
+ }
+ }
+}
+
+pub fn set_channel(
+ animations: Option<&ElementAnimations>,
+ property_path: &str,
+ channel: Option<&AnimationChannel>,
+) -> Option<ElementAnimations> {
+ set_binding_component_channel(animations, property_path, "value", channel)
+}
+
+pub fn set_binding_component_channel(
+ animations: Option<&ElementAnimations>,
+ property_path: &str,
+ component_key: &str,
+ channel: Option<&AnimationChannel>,
+) -> Option<ElementAnimations> {
+ let mut next_animations = animations.cloned().unwrap_or_default();
+ let prepared = channel
+ .filter(|channel| has_channel_keys(Some(channel)))
+ .map(normalize_channel);
+ let data = set_channel_in_data(
+ next_animations.get(property_path).cloned(),
+ component_key,
+ prepared,
+ );
+ match data {
+ Some(data) => {
+ next_animations.insert(property_path.to_string(), data);
+ }
+ None => {
+ next_animations.remove(property_path);
+ }
+ }
+ to_animation(next_animations)
+}
+
+pub fn update_scalar_keyframe_curve(
+ animations: Option<&ElementAnimations>,
+ property_path: &str,
+ component_key: &str,
+ keyframe_id: &str,
+ patch: &ScalarCurveKeyframePatch,
+) -> Option<ElementAnimations> {
+ let channel = get_channel_from_data(
+ animations.and_then(|a| a.get(property_path)),
+ component_key,
+ );
+ let Some(AnimationChannel::Scalar(scalar)) = channel else {
+ return animations.cloned();
+ };
+
+ let Some(keyframe_index) = scalar.keys.iter().position(|key| key.id == keyframe_id) else {
+ return animations.cloned();
+ };
+
+ let mut keys = scalar.keys.clone();
+ let current_key = &keys[keyframe_index];
+ keys[keyframe_index] = ScalarAnimationKey {
+ left_handle: patch.left_handle.unwrap_or(current_key.left_handle),
+ right_handle: patch.right_handle.unwrap_or(current_key.right_handle),
+ segment_to_next: patch.segment_to_next.unwrap_or(current_key.segment_to_next),
+ tangent_mode: patch.tangent_mode.unwrap_or(current_key.tangent_mode),
+ ..current_key.clone()
+ };
+
+ set_binding_component_channel(
+ animations,
+ property_path,
+ component_key,
+ Some(&AnimationChannel::Scalar(ScalarChannel {
+ keys,
+ extrapolation: scalar.extrapolation.clone(),
+ })),
+ )
+}
+
+fn clone_channel_with_key_ids(
+ channel: &AnimationChannel,
+ key_id_map: &HashMap<String, String>,
+) -> AnimationChannel {
+ let remap = |id: &str| key_id_map.get(id).cloned().unwrap_or_else(|| id.to_string());
+ match channel {
+ AnimationChannel::Scalar(scalar) => AnimationChannel::Scalar(normalize_scalar_channel(
+ &ScalarChannel {
+ keys: scalar
+ .keys
+ .iter()
+ .map(|key| ScalarAnimationKey {
+ id: remap(&key.id),
+ ..key.clone()
+ })
+ .collect(),
+ extrapolation: scalar.extrapolation.clone(),
+ },
+ )),
+ AnimationChannel::Discrete(discrete) => {
+ AnimationChannel::Discrete(normalize_discrete_channel(&DiscreteChannel {
+ keys: discrete
+ .keys
+ .iter()
+ .map(|key| DiscreteAnimationKey {
+ id: remap(&key.id),
+ ..key.clone()
+ })
+ .collect(),
+ }))
+ }
+ }
+}
+
+pub fn clone_animations(
+ animations: Option<&ElementAnimations>,
+ regenerate_keyframe_ids: bool,
+) -> Option<ElementAnimations> {
+ let animations = animations?;
+
+ let mut next_animations = animations.clone();
+ for (property_path, data) in animations
+ .iter()
+ .filter(|(key, _)| is_animation_storage_key(key))
+ {
+ let channels = get_channels_from_data(Some(data));
+ let mut key_id_map = HashMap::new();
+ if let Some(primary_channel) = channels.first() {
+ for (id, _) in channel_key_ids_and_times(primary_channel) {
+ let next_id = if regenerate_keyframe_ids {
+ generate_uuid()
+ } else {
+ id.clone()
+ };
+ key_id_map.insert(id, next_id);
+ }
+ }
+
+ let next_data = match data {
+ ChannelData::Single(channel) => {
+ ChannelData::Single(clone_channel_with_key_ids(channel, &key_id_map))
+ }
+ ChannelData::Composite(components) => ChannelData::Composite(
+ components
+ .iter()
+ .map(|(component_key, channel)| {
+ (
+ component_key.clone(),
+ clone_channel_with_key_ids(channel, &key_id_map),
+ )
+ })
+ .collect(),
+ ),
+ };
+ next_animations.insert(property_path.clone(), next_data);
+ }
+
+ to_animation(next_animations)
+}
+
+pub fn clamp_animations_to_duration(
+ animations: Option<&ElementAnimations>,
+ duration: MediaTime,
+) -> Option<ElementAnimations> {
+ let animations = animations?;
+ if duration <= MediaTime::ZERO {
+ return None;
+ }
+ split_animations_at_time_with_options(Some(animations), duration, true).0
+}
+
+#[derive(Clone, Copy, Debug)]
+struct Point {
+ x: f64,
+ y: f64,
+}
+
+fn lerp_point(left: Point, right: Point, progress: f64) -> Point {
+ Point {
+ x: left.x + (right.x - left.x) * progress,
+ y: left.y + (right.y - left.y) * progress,
+ }
+}
+
+pub struct SplitChannelResult {
+ pub left_channel: Option<AnimationChannel>,
+ pub right_channel: Option<AnimationChannel>,
+}
+
+fn split_discrete_channel_at_time(
+ channel: Option<&DiscreteChannel>,
+ split_time: MediaTime,
+ left_boundary_id: &str,
+ right_boundary_id: &str,
+ include_split_boundary: bool,
+) -> SplitChannelResult {
+ let Some(channel) = channel.filter(|channel| !channel.keys.is_empty()) else {
+ return SplitChannelResult {
+ left_channel: None,
+ right_channel: None,
+ };
+ };
+
+ let normalized = normalize_discrete_channel(channel);
+ let mut left_keys: Vec<DiscreteAnimationKey> = normalized
+ .keys
+ .iter()
+ .filter(|key| key.time <= split_time)
+ .cloned()
+ .collect();
+ let mut right_keys: Vec<DiscreteAnimationKey> = normalized
+ .keys
+ .iter()
+ .filter(|key| key.time >= split_time)
+ .map(|key| DiscreteAnimationKey {
+ time: key.time - split_time,
+ ..key.clone()
+ })
+ .collect();
+
+ if include_split_boundary {
+ let has_boundary_on_left = left_keys.iter().any(|key| key.time == split_time);
+ let has_boundary_on_right = right_keys.iter().any(|key| key.time == MediaTime::ZERO);
+ let boundary_value = discrete_value_at_time(
+ &normalized,
+ split_time,
+ normalized.keys[0].value.clone(),
+ );
+ if !has_boundary_on_left {
+ left_keys.push(DiscreteAnimationKey {
+ id: left_boundary_id.to_string(),
+ time: split_time,
+ value: boundary_value.clone(),
+ });
+ }
+ if !has_boundary_on_right {
+ right_keys.insert(
+ 0,
+ DiscreteAnimationKey {
+ id: right_boundary_id.to_string(),
+ time: MediaTime::ZERO,
+ value: boundary_value,
+ },
+ );
+ }
+ }
+
+ SplitChannelResult {
+ left_channel: if left_keys.is_empty() {
+ None
+ } else {
+ Some(AnimationChannel::Discrete(normalize_discrete_channel(
+ &DiscreteChannel { keys: left_keys },
+ )))
+ },
+ right_channel: if right_keys.is_empty() {
+ None
+ } else {
+ Some(AnimationChannel::Discrete(normalize_discrete_channel(
+ &DiscreteChannel { keys: right_keys },
+ )))
+ },
+ }
+}
+
+fn discrete_value_at_time(
+ channel: &DiscreteChannel,
+ time: MediaTime,
+ fallback_value: DiscreteValue,
+) -> DiscreteValue {
+ let ParamValue::Bool(_) = ParamValue::Bool(false) else {
+ unreachable!()
+ };
+ crate::interpolation::discrete_channel_value_at_time(Some(channel), time, fallback_value)
+}
+
+fn build_split_scalar_result(
+ left_keys: Vec<ScalarAnimationKey>,
+ right_keys: Vec<ScalarAnimationKey>,
+ extrapolation: Option<scene::ChannelExtrapolation>,
+) -> SplitChannelResult {
+ SplitChannelResult {
+ left_channel: if left_keys.is_empty() {
+ None
+ } else {
+ Some(AnimationChannel::Scalar(normalize_scalar_channel(
+ &ScalarChannel {
+ keys: left_keys,
+ extrapolation: extrapolation.clone(),
+ },
+ )))
+ },
+ right_channel: if right_keys.is_empty() {
+ None
+ } else {
+ Some(AnimationChannel::Scalar(normalize_scalar_channel(
+ &ScalarChannel {
+ keys: right_keys,
+ extrapolation,
+ },
+ )))
+ },
+ }
+}
+
+fn split_scalar_channel_at_time(
+ channel: Option<&ScalarChannel>,
+ split_time: MediaTime,
+ left_boundary_id: &str,
+ right_boundary_id: &str,
+ include_split_boundary: bool,
+) -> SplitChannelResult {
+ let Some(channel) = channel.filter(|channel| !channel.keys.is_empty()) else {
+ return SplitChannelResult {
+ left_channel: None,
+ right_channel: None,
+ };
+ };
+
+ let normalized = normalize_scalar_channel(channel);
+ let mut left_keys: Vec<ScalarAnimationKey> = normalized
+ .keys
+ .iter()
+ .filter(|key| key.time <= split_time)
+ .cloned()
+ .collect();
+ let mut right_keys: Vec<ScalarAnimationKey> = normalized
+ .keys
+ .iter()
+ .filter(|key| key.time >= split_time)
+ .map(|key| ScalarAnimationKey {
+ time: key.time - split_time,
+ ..key.clone()
+ })
+ .collect();
+
+ let has_boundary_on_left = left_keys.iter().any(|key| key.time == split_time);
+ let has_boundary_on_right = right_keys.iter().any(|key| key.time == MediaTime::ZERO);
+ if !include_split_boundary || (has_boundary_on_left && has_boundary_on_right) {
+ return build_split_scalar_result(
+ left_keys,
+ right_keys,
+ normalized.extrapolation.clone(),
+ );
+ }
+
+ for key_index in 0..normalized.keys.len().saturating_sub(1) {
+ let left_key = &normalized.keys[key_index];
+ let right_key = &normalized.keys[key_index + 1];
+ if split_time <= left_key.time || split_time >= right_key.time {
+ continue;
+ }
+
+ let boundary_value = crate::interpolation::scalar_channel_value_at_time(
+ Some(&normalized),
+ split_time,
+ left_key.value,
+ );
+
+ if left_key.segment_to_next == ScalarSegmentType::Bezier {
+ let (right_handle, left_handle) = segment_handles(left_key, right_key);
+ let progress = solve_bezier_progress_for_time(split_time, left_key, right_key);
+ let p0 = Point {
+ x: left_key.time.as_ticks() as f64,
+ y: left_key.value,
+ };
+ let p1 = Point {
+ x: p0.x + right_handle.dt,
+ y: p0.y + right_handle.dv,
+ };
+ let p3 = Point {
+ x: right_key.time.as_ticks() as f64,
+ y: right_key.value,
+ };
+ let p2 = Point {
+ x: p3.x + left_handle.dt,
+ y: p3.y + left_handle.dv,
+ };
+ let q0 = lerp_point(p0, p1, progress);
+ let q1 = lerp_point(p1, p2, progress);
+ let q2 = lerp_point(p2, p3, progress);
+ let r0 = lerp_point(q0, q1, progress);
+ let r1 = lerp_point(q1, q2, progress);
+ let split_point = lerp_point(r0, r1, progress);
+
+ let mut next_left_keys: Vec<ScalarAnimationKey> = normalized
+ .keys
+ .iter()
+ .filter(|key| key.time < split_time)
+ .cloned()
+ .collect();
+ next_left_keys.push(ScalarAnimationKey {
+ right_handle: Some(scene::CurveHandle {
+ dt: round_media_time(q0.x - p0.x),
+ dv: q0.y - p0.y,
+ }),
+ ..left_key.clone()
+ });
+ next_left_keys.push(ScalarAnimationKey {
+ id: left_boundary_id.to_string(),
+ time: split_time,
+ value: boundary_value,
+ left_handle: Some(scene::CurveHandle {
+ dt: round_media_time(r0.x - split_point.x),
+ dv: r0.y - split_point.y,
+ }),
+ right_handle: None,
+ segment_to_next: left_key.segment_to_next,
+ tangent_mode: left_key.tangent_mode,
+ });
+
+ let mut next_right_keys: Vec<ScalarAnimationKey> = vec![
+ ScalarAnimationKey {
+ id: right_boundary_id.to_string(),
+ time: MediaTime::ZERO,
+ value: boundary_value,
+ left_handle: None,
+ right_handle: Some(scene::CurveHandle {
+ dt: round_media_time(r1.x - split_point.x),
+ dv: r1.y - split_point.y,
+ }),
+ segment_to_next: ScalarSegmentType::Bezier,
+ tangent_mode: left_key.tangent_mode,
+ },
+ ScalarAnimationKey {
+ time: right_key.time - split_time,
+ left_handle: Some(scene::CurveHandle {
+ dt: round_media_time(q2.x - p3.x),
+ dv: q2.y - p3.y,
+ }),
+ ..right_key.clone()
+ },
+ ];
+ next_right_keys.extend(
+ normalized
+ .keys
+ .iter()
+ .filter(|key| key.time > right_key.time)
+ .map(|key| ScalarAnimationKey {
+ time: key.time - split_time,
+ ..key.clone()
+ }),
+ );
+
+ return SplitChannelResult {
+ left_channel: Some(AnimationChannel::Scalar(normalize_scalar_channel(
+ &ScalarChannel {
+ keys: next_left_keys,
+ extrapolation: normalized.extrapolation.clone(),
+ },
+ ))),
+ right_channel: Some(AnimationChannel::Scalar(normalize_scalar_channel(
+ &ScalarChannel {
+ keys: next_right_keys,
+ extrapolation: normalized.extrapolation.clone(),
+ },
+ ))),
+ };
+ }
+
+ left_keys.push(create_scalar_key(
+ left_boundary_id.to_string(),
+ split_time,
+ boundary_value,
+ Some(AnimationInterpolation::Linear),
+ None,
+ ));
+ right_keys.insert(
+ 0,
+ create_scalar_key(
+ right_boundary_id.to_string(),
+ MediaTime::ZERO,
+ boundary_value,
+ Some(scalar_segment_interpolation(left_key.segment_to_next)),
+ None,
+ ),
+ );
+ return build_split_scalar_result(
+ left_keys,
+ right_keys,
+ normalized.extrapolation.clone(),
+ );
+ }
+
+ build_split_scalar_result(left_keys, right_keys, normalized.extrapolation.clone())
+}
+
+fn split_channel_at_time(
+ channel: Option<&AnimationChannel>,
+ split_time: MediaTime,
+ left_boundary_id: &str,
+ right_boundary_id: &str,
+ include_split_boundary: bool,
+) -> SplitChannelResult {
+ match channel {
+ Some(AnimationChannel::Discrete(discrete)) => split_discrete_channel_at_time(
+ Some(discrete),
+ split_time,
+ left_boundary_id,
+ right_boundary_id,
+ include_split_boundary,
+ ),
+ scalar => split_scalar_channel_at_time(
+ match scalar {
+ Some(AnimationChannel::Scalar(scalar)) => Some(scalar),
+ _ => None,
+ },
+ split_time,
+ left_boundary_id,
+ right_boundary_id,
+ include_split_boundary,
+ ),
+ }
+}
+
+pub fn split_animations_at_time_with_options(
+ animations: Option<&ElementAnimations>,
+ split_time: MediaTime,
+ include_split_boundary: bool,
+) -> (Option<ElementAnimations>, Option<ElementAnimations>) {
+ let Some(animations) = animations else {
+ return (None, None);
+ };
+
+ let mut left_animations = ElementAnimations::new();
+ let mut right_animations = ElementAnimations::new();
+
+ for (property_path, data) in animations
+ .iter()
+ .filter(|(key, _)| is_animation_storage_key(key))
+ {
+ let left_boundary_id = generate_uuid();
+ let right_boundary_id = generate_uuid();
+
+ for (component_key, channel) in get_channel_entries_from_data(Some(data)) {
+ let split = split_channel_at_time(
+ Some(channel),
+ split_time,
+ &left_boundary_id,
+ &right_boundary_id,
+ include_split_boundary,
+ );
+ if let Some(left_channel) = split.left_channel {
+ if let Some(data) = set_channel_in_data(
+ left_animations.get(property_path).cloned(),
+ &component_key,
+ Some(left_channel),
+ ) {
+ left_animations.insert(property_path.clone(), data);
+ }
+ }
+ if let Some(right_channel) = split.right_channel {
+ if let Some(data) = set_channel_in_data(
+ right_animations.get(property_path).cloned(),
+ &component_key,
+ Some(right_channel),
+ ) {
+ right_animations.insert(property_path.clone(), data);
+ }
+ }
+ }
+ }
+
+ (to_animation(left_animations), to_animation(right_animations))
+}
+
+pub fn split_animations_at_time(
+ animations: &ElementAnimations,
+ split_time: MediaTime,
+) -> (ElementAnimations, ElementAnimations) {
+ let (left, right) = split_animations_at_time_with_options(Some(animations), split_time, true);
+ (left.unwrap_or_default(), right.unwrap_or_default())
+}
+
+pub fn remove_element_keyframe(
+ animations: Option<&ElementAnimations>,
+ property_path: &str,
+ keyframe_id: &str,
+) -> Option<ElementAnimations> {
+ let data = animations.and_then(|a| a.get(property_path))?;
+
+ let mut next_animations = animations.cloned().unwrap_or_default();
+ let next_data = match data {
+ ChannelData::Single(channel) => remove_keyframe(Some(channel), keyframe_id),
+ ChannelData::Composite(components) => {
+ let mut next_data = Some(data.clone());
+ for (component_key, channel) in components {
+ next_data = set_channel_in_data(
+ next_data,
+ component_key,
+ remove_keyframe(Some(channel), keyframe_id),
+ );
+ }
+ next_data
+ }
+ };
+ match next_data {
+ Some(data) => {
+ next_animations.insert(property_path.to_string(), data);
+ }
+ None => {
+ next_animations.remove(property_path);
+ }
+ }
+ to_animation(next_animations)
+}
+
+pub fn retime_element_keyframe(
+ animations: Option<&ElementAnimations>,
+ property_path: &str,
+ keyframe_id: &str,
+ time: MediaTime,
+) -> Option<ElementAnimations> {
+ let data = animations.and_then(|a| a.get(property_path))?;
+
+ let mut next_animations = animations.cloned().unwrap_or_default();
+ let next_data = match data {
+ ChannelData::Single(channel) => retime_keyframe(Some(channel), keyframe_id, time),
+ ChannelData::Composite(components) => {
+ let mut next_data = Some(data.clone());
+ for (component_key, channel) in components {
+ next_data = set_channel_in_data(
+ next_data,
+ component_key,
+ retime_keyframe(Some(channel), keyframe_id, time),
+ );
+ }
+ next_data
+ }
+ };
+ match next_data {
+ Some(data) => {
+ next_animations.insert(property_path.to_string(), data);
+ }
+ None => {
+ next_animations.remove(property_path);
+ }
+ }
+ to_animation(next_animations)
+}
diff --git a/rust/crates/animation/src/lib.rs b/rust/crates/animation/src/lib.rs
new file mode 100644
index 00000000..8b137891
--- /dev/null
+++ b/rust/crates/animation/src/lib.rs
@@ -0,0 +1 @@
+
diff --git a/rust/crates/animation/src/types.rs b/rust/crates/animation/src/types.rs
new file mode 100644
index 00000000..ca0c3c20
--- /dev/null
+++ b/rust/crates/animation/src/types.rs
@@ -0,0 +1,45 @@
+use scene::{CurveHandle, ParamValue, ScalarSegmentType, TangentMode};
+use serde::{Deserialize, Serialize};
+use time::MediaTime;
+
+#[derive(Serialize, Deserialize, Clone, Copy, Debug, Eq, PartialEq)]
+#[serde(rename_all = "camelCase")]
+pub enum AnimationInterpolation {
+ Linear,
+ Hold,
+ Bezier,
+}
+
+#[derive(Serialize, Deserialize, Clone, Copy, Debug, Default, PartialEq)]
+#[serde(rename_all = "camelCase")]
+pub struct NumericSpec {
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub min: Option<f64>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub max: Option<f64>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub step: Option<f64>,
+}
+
+#[derive(Serialize, Deserialize, Clone, Copy, Debug, Default, PartialEq)]
+#[serde(rename_all = "camelCase")]
+pub struct ScalarCurveKeyframePatch {
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub left_handle: Option<Option<CurveHandle>>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub right_handle: Option<Option<CurveHandle>>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub segment_to_next: Option<ScalarSegmentType>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub tangent_mode: Option<TangentMode>,
+}
+
+#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
+#[serde(rename_all = "camelCase")]
+pub struct ElementKeyframe {
+ pub property_path: String,
+ pub id: String,
+ pub time: MediaTime,
+ pub value: ParamValue,
+ pub interpolation: AnimationInterpolation,
+}
diff --git a/rust/crates/commands/Cargo.toml b/rust/crates/commands/Cargo.toml
new file mode 100644
index 00000000..ef5bc895
--- /dev/null
+++ b/rust/crates/commands/Cargo.toml
@@ -0,0 +1,15 @@
+[package]
+name = "commands"
+version = "0.1.0"
+edition = "2024"
+
+[dependencies]
+animation = { version = "0.1.0", path = "../animation" }
+chrono = { version = "0.4", features = ["serde"] }
+scene = { version = "0.1.0", path = "../scene" }
+serde = { version = "1.0", features = ["derive"] }
+serde_json = "1.0"
+thiserror = "2"
+time = { version = "0.1.0", path = "../time" }
+timeline = { version = "0.1.0", path = "../timeline" }
+uuid = { version = "1", features = ["v4"] }
diff --git a/rust/crates/commands/src/batch_command.rs b/rust/crates/commands/src/batch_command.rs
new file mode 100644
index 00000000..bd928d33
--- /dev/null
+++ b/rust/crates/commands/src/batch_command.rs
@@ -0,0 +1,61 @@
+use scene::Project;
+
+use crate::command::{Command, CommandError, CommandResult};
+
+pub struct BatchCommand {
+ commands: Vec<Box<dyn Command>>,
+}
+
+impl BatchCommand {
+ pub fn new(commands: Vec<Box<dyn Command>>) -> Self {
+ Self { commands }
+ }
+
+ pub fn len(&self) -> usize {
+ self.commands.len()
+ }
+
+ pub fn is_empty(&self) -> bool {
+ self.commands.is_empty()
+ }
+}
+
+impl Command for BatchCommand {
+ fn execute(
+ &mut self,
+ project: &mut Project,
+ ) -> Result<Option<CommandResult>, CommandError> {
+ let mut latest_selection_result = None;
+
+ for command in &mut self.commands {
+ let result = command.execute(project)?;
+ if result.as_ref().and_then(|r| r.selection.as_ref()).is_some() {
+ latest_selection_result = result;
+ }
+ }
+
+ Ok(latest_selection_result)
+ }
+
+ fn undo(&mut self, project: &mut Project) {
+ for command in self.commands.iter_mut().rev() {
+ command.undo(project);
+ }
+ }
+
+ fn redo(
+ &mut self,
+ project: &mut Project,
+ ) -> Result<Option<CommandResult>, CommandError> {
+ let mut latest_selection_result = None;
+
+ for command in &mut self.commands {
+ let result = command.redo(project)?;
+ if result.as_ref().and_then(|r| r.selection.as_ref()).is_some() {
+ latest_selection_result = result;
+ }
+ }
+
+ Ok(latest_selection_result)
+ }
+}
diff --git a/rust/crates/commands/src/bookmarks.rs b/rust/crates/commands/src/bookmarks.rs
new file mode 100644
index 00000000..79a73015
--- /dev/null
+++ b/rust/crates/commands/src/bookmarks.rs
@@ -0,0 +1,94 @@
+use scene::Bookmark;
+use time::{FrameRate, MediaTime};
+
+#[derive(Clone, Debug, Default, PartialEq)]
+pub struct BookmarkPatch {
+ pub note: Option<Option<String>>,
+ pub color: Option<Option<String>>,
+ pub duration: Option<Option<MediaTime>>,
+}
+
+pub fn find_bookmark_index(bookmarks: &[Bookmark], frame_time: MediaTime) -> Option<usize> {
+ bookmarks.iter().position(|bookmark| bookmark.time == frame_time)
+}
+
+pub fn is_bookmark_at_time(bookmarks: &[Bookmark], frame_time: MediaTime) -> bool {
+ find_bookmark_index(bookmarks, frame_time).is_some()
+}
+
+pub fn toggle_bookmark_in_array(
+ bookmarks: &[Bookmark],
+ frame_time: MediaTime,
+) -> Vec<Bookmark> {
+ if let Some(index) = find_bookmark_index(bookmarks, frame_time) {
+ return bookmarks
+ .iter()
+ .enumerate()
+ .filter(|(bookmark_index, _)| *bookmark_index != index)
+ .map(|(_, bookmark)| bookmark.clone())
+ .collect();
+ }
+
+ let mut next = bookmarks.to_vec();
+ next.push(Bookmark {
+ time: frame_time,
+ note: None,
+ color: None,
+ duration: None,
+ });
+ next.sort_by_key(|bookmark| bookmark.time);
+ next
+}
+
+pub fn remove_bookmark_from_array(
+ bookmarks: &[Bookmark],
+ frame_time: MediaTime,
+) -> Vec<Bookmark> {
+ bookmarks
+ .iter()
+ .filter(|bookmark| bookmark.time != frame_time)
+ .cloned()
+ .collect()
+}
+
+pub fn update_bookmark_in_array(
+ bookmarks: &[Bookmark],
+ frame_time: MediaTime,
+ updates: &BookmarkPatch,
+) -> Vec<Bookmark> {
+ let Some(index) = find_bookmark_index(bookmarks, frame_time) else {
+ return bookmarks.to_vec();
+ };
+
+ let mut next = bookmarks.to_vec();
+ let bookmark = &mut next[index];
+ if let Some(note) = &updates.note {
+ bookmark.note = note.clone();
+ }
+ if let Some(color) = &updates.color {
+ bookmark.color = color.clone();
+ }
+ if let Some(duration) = &updates.duration {
+ bookmark.duration = *duration;
+ }
+ next
+}
+
+pub fn move_bookmark_in_array(
+ bookmarks: &[Bookmark],
+ from_time: MediaTime,
+ to_time: MediaTime,
+) -> Vec<Bookmark> {
+ let Some(index) = find_bookmark_index(bookmarks, from_time) else {
+ return bookmarks.to_vec();
+ };
+
+ let mut next = bookmarks.to_vec();
+ next[index].time = to_time;
+ next.sort_by_key(|bookmark| bookmark.time);
+ next
+}
+
+pub fn get_frame_time(time: MediaTime, fps: FrameRate) -> MediaTime {
+ time.round_to_frame(fps).unwrap_or(time)
+}
diff --git a/rust/crates/commands/src/command.rs b/rust/crates/commands/src/command.rs
new file mode 100644
index 00000000..c13468e7
--- /dev/null
+++ b/rust/crates/commands/src/command.rs
@@ -0,0 +1,46 @@
+use scene::{ElementRef, Project};
+use thiserror::Error;
+
+#[derive(Debug, Error)]
+pub enum CommandError {
+ #[error("source track or element not found")]
+ SourceNotFound,
+ #[error("target track not found")]
+ TargetTrackNotFound,
+ #[error("{0}")]
+ IncompatiblePlacement(String),
+}
+
+#[derive(Clone, Debug, Default, PartialEq)]
+pub struct SelectionPatch {
+ pub selected_elements: Vec<ElementRef>,
+}
+
+#[derive(Clone, Debug, Default, PartialEq)]
+pub struct CommandResult {
+ pub selection: Option<SelectionPatch>,
+}
+
+impl CommandResult {
+ pub fn element_selection(selected_elements: Vec<ElementRef>) -> Self {
+ Self {
+ selection: Some(SelectionPatch { selected_elements }),
+ }
+ }
+}
+
+pub trait Command {
+ fn execute(
+ &mut self,
+ project: &mut Project,
+ ) -> Result<Option<CommandResult>, CommandError>;
+
+ fn undo(&mut self, project: &mut Project);
+
+ fn redo(
+ &mut self,
+ project: &mut Project,
+ ) -> Result<Option<CommandResult>, CommandError> {
+ self.execute(project)
+ }
+}
diff --git a/rust/crates/commands/src/history.rs b/rust/crates/commands/src/history.rs
new file mode 100644
index 00000000..f8792373
--- /dev/null
+++ b/rust/crates/commands/src/history.rs
@@ -0,0 +1,68 @@
+use scene::Project;
+
+use crate::command::{Command, CommandError, CommandResult};
+
+#[derive(Default)]
+pub struct CommandHistory {
+ history: Vec<Box<dyn Command>>,
+ redo_stack: Vec<Box<dyn Command>>,
+}
+
+impl CommandHistory {
+ pub fn new() -> Self {
+ Self::default()
+ }
+
+ pub fn execute(
+ &mut self,
+ mut command: Box<dyn Command>,
+ project: &mut Project,
+ ) -> Result<Option<CommandResult>, CommandError> {
+ let result = command.execute(project)?;
+ self.history.push(command);
+ self.redo_stack.clear();
+ Ok(result)
+ }
+
+ pub fn push(&mut self, command: Box<dyn Command>) {
+ self.history.push(command);
+ self.redo_stack.clear();
+ }
+
+ pub fn undo(&mut self, project: &mut Project) {
+ let Some(mut command) = self.history.pop() else {
+ return;
+ };
+ command.undo(project);
+ self.redo_stack.push(command);
+ }
+
+ pub fn redo(
+ &mut self,
+ project: &mut Project,
+ ) -> Result<Option<CommandResult>, CommandError> {
+ let Some(mut command) = self.redo_stack.pop() else {
+ return Ok(None);
+ };
+ let result = command.redo(project)?;
+ self.history.push(command);
+ Ok(result)
+ }
+
+ pub fn can_undo(&self) -> bool {
+ !self.history.is_empty()
+ }
+
+ pub fn can_redo(&self) -> bool {
+ !self.redo_stack.is_empty()
+ }
+
+ pub fn len(&self) -> usize {
+ self.history.len()
+ }
+
+ pub fn clear(&mut self) {
+ self.history.clear();
+ self.redo_stack.clear();
+ }
+}
diff --git a/rust/crates/commands/src/lib.rs b/rust/crates/commands/src/lib.rs
new file mode 100644
index 00000000..3fede6bb
--- /dev/null
+++ b/rust/crates/commands/src/lib.rs
@@ -0,0 +1,18 @@
+pub mod batch_command;
+pub mod bookmarks;
+pub mod command;
+pub mod history;
+pub mod preview_tracker;
+pub mod project;
+pub mod project_ext;
+pub mod scene;
+pub mod timeline;
+pub mod tracks;
+
+pub use batch_command::BatchCommand;
+pub use command::{Command, CommandError, CommandResult, SelectionPatch};
+pub use history::CommandHistory;
+pub use preview_tracker::PreviewTracker;
+pub use project::*;
+pub use scene::*;
+pub use timeline::*;
diff --git a/rust/crates/commands/src/preview_tracker.rs b/rust/crates/commands/src/preview_tracker.rs
new file mode 100644
index 00000000..4eb7484d
--- /dev/null
+++ b/rust/crates/commands/src/preview_tracker.rs
@@ -0,0 +1,28 @@
+#[derive(Clone, Debug, Default)]
+pub struct PreviewTracker<T> {
+ snapshot: Option<T>,
+}
+
+impl<T: Clone> PreviewTracker<T> {
+ pub fn new() -> Self {
+ Self { snapshot: None }
+ }
+
+ pub fn begin(&mut self, state: &T) {
+ if self.snapshot.is_none() {
+ self.snapshot = Some(state.clone());
+ }
+ }
+
+ pub fn is_active(&self) -> bool {
+ self.snapshot.is_some()
+ }
+
+ pub fn snapshot(&self) -> Option<&T> {
+ self.snapshot.as_ref()
+ }
+
+ pub fn end(&mut self) -> Option<T> {
+ self.snapshot.take()
+ }
+}
diff --git a/rust/crates/commands/src/project/mod.rs b/rust/crates/commands/src/project/mod.rs
new file mode 100644
index 00000000..28e9c92c
--- /dev/null
+++ b/rust/crates/commands/src/project/mod.rs
@@ -0,0 +1,3 @@
+pub mod update_project_settings;
+
+pub use update_project_settings::{ProjectSettingsPatch, UpdateProjectSettingsCommand};
diff --git a/rust/crates/commands/src/project/update_project_settings.rs b/rust/crates/commands/src/project/update_project_settings.rs
new file mode 100644
index 00000000..a0f6e99c
--- /dev/null
+++ b/rust/crates/commands/src/project/update_project_settings.rs
@@ -0,0 +1,77 @@
+use scene::{Background, CanvasSize, CanvasSizeMode, Project, ProjectSettings};
+use time::FrameRate;
+
+use crate::command::{Command, CommandError, CommandResult};
+
+#[derive(Clone, Debug, Default, PartialEq)]
+pub struct ProjectSettingsPatch {
+ pub fps: Option<FrameRate>,
+ pub canvas_size: Option<CanvasSize>,
+ pub canvas_size_mode: Option<Option<CanvasSizeMode>>,
+ pub last_custom_canvas_size: Option<Option<CanvasSize>>,
+ pub original_canvas_size: Option<Option<CanvasSize>>,
+ pub background: Option<Background>,
+}
+
+impl ProjectSettingsPatch {
+ pub fn apply_to(&self, settings: &mut ProjectSettings) {
+ if let Some(fps) = self.fps {
+ settings.fps = fps;
+ }
+ if let Some(canvas_size) = self.canvas_size {
+ settings.canvas_size = canvas_size;
+ }
+ if let Some(canvas_size_mode) = &self.canvas_size_mode {
+ settings.canvas_size_mode = *canvas_size_mode;
+ }
+ if let Some(last_custom_canvas_size) = &self.last_custom_canvas_size {
+ settings.last_custom_canvas_size = *last_custom_canvas_size;
+ }
+ if let Some(original_canvas_size) = &self.original_canvas_size {
+ settings.original_canvas_size = *original_canvas_size;
+ }
+ if let Some(background) = &self.background {
+ settings.background = background.clone();
+ }
+ }
+}
+
+pub struct UpdateProjectSettingsCommand {
+ updates: ProjectSettingsPatch,
+ saved_settings: Option<ProjectSettings>,
+ saved_updated_at: Option<chrono::DateTime<chrono::Utc>>,
+}
+
+impl UpdateProjectSettingsCommand {
+ pub fn new(updates: ProjectSettingsPatch) -> Self {
+ Self {
+ updates,
+ saved_settings: None,
+ saved_updated_at: None,
+ }
+ }
+}
+
+impl Command for UpdateProjectSettingsCommand {
+ fn execute(
+ &mut self,
+ project: &mut Project,
+ ) -> Result<Option<CommandResult>, CommandError> {
+ self.saved_settings = Some(project.settings.clone());
+ self.saved_updated_at = Some(project.metadata.updated_at);
+
+ self.updates.apply_to(&mut project.settings);
+ project.metadata.updated_at = chrono::Utc::now();
+ Ok(None)
+ }
+
+ fn undo(&mut self, project: &mut Project) {
+ let (Some(saved_settings), Some(saved_updated_at)) =
+ (&self.saved_settings, self.saved_updated_at)
+ else {
+ return;
+ };
+ project.settings = saved_settings.clone();
+ project.metadata.updated_at = saved_updated_at;
+ }
+}
diff --git a/rust/crates/commands/src/project_ext.rs b/rust/crates/commands/src/project_ext.rs
new file mode 100644
index 00000000..18262fbd
--- /dev/null
+++ b/rust/crates/commands/src/project_ext.rs
@@ -0,0 +1,20 @@
+use scene::{Project, Scene};
+
+pub fn active_scene(project: &Project) -> Option<&Scene> {
+ project
+ .scenes
+ .iter()
+ .find(|scene| scene.id == project.current_scene_id)
+ .or_else(|| project.scenes.iter().find(|scene| scene.is_main))
+ .or_else(|| project.scenes.first())
+}
+
+pub fn active_scene_mut(project: &mut Project) -> Option<&mut Scene> {
+ let index = project
+ .scenes
+ .iter()
+ .position(|scene| scene.id == project.current_scene_id)
+ .or_else(|| project.scenes.iter().position(|scene| scene.is_main))
+ .or(if project.scenes.is_empty() { None } else { Some(0) })?;
+ project.scenes.get_mut(index)
+}
diff --git a/rust/crates/commands/src/scene/create_scene.rs b/rust/crates/commands/src/scene/create_scene.rs
new file mode 100644
index 00000000..1f2de02b
--- /dev/null
+++ b/rust/crates/commands/src/scene/create_scene.rs
@@ -0,0 +1,46 @@
+use scene::{Project, Scene};
+
+use crate::command::{Command, CommandError, CommandResult};
+
+pub struct CreateSceneCommand {
+ name: String,
+ is_main: bool,
+ saved_scenes: Option<Vec<Scene>>,
+ created_scene_id: Option<String>,
+}
+
+impl CreateSceneCommand {
+ pub fn new(name: impl Into<String>, is_main: bool) -> Self {
+ Self {
+ name: name.into(),
+ is_main,
+ saved_scenes: None,
+ created_scene_id: None,
+ }
+ }
+
+ pub fn scene_id(&self) -> Option<&str> {
+ self.created_scene_id.as_deref()
+ }
+}
+
+impl Command for CreateSceneCommand {
+ fn execute(
+ &mut self,
+ project: &mut Project,
+ ) -> Result<Option<CommandResult>, CommandError> {
+ self.saved_scenes = Some(project.scenes.clone());
+
+ let created = timeline::scenes::build_default_scene(&self.name, self.is_main);
+ self.created_scene_id = Some(created.id.clone());
+ project.scenes.push(created);
+ Ok(None)
+ }
+
+ fn undo(&mut self, project: &mut Project) {
+ let Some(saved) = &self.saved_scenes else {
+ return;
+ };
+ project.scenes = saved.clone();
+ }
+}
diff --git a/rust/crates/commands/src/scene/delete_scene.rs b/rust/crates/commands/src/scene/delete_scene.rs
new file mode 100644
index 00000000..61763dfd
--- /dev/null
+++ b/rust/crates/commands/src/scene/delete_scene.rs
@@ -0,0 +1,61 @@
+use scene::{Project, Scene};
+
+use crate::command::{Command, CommandError, CommandResult};
+
+pub struct DeleteSceneCommand {
+ scene_id: String,
+ saved_scenes: Option<Vec<Scene>>,
+ saved_current_scene_id: Option<String>,
+}
+
+impl DeleteSceneCommand {
+ pub fn new(scene_id: impl Into<String>) -> Self {
+ Self {
+ scene_id: scene_id.into(),
+ saved_scenes: None,
+ saved_current_scene_id: None,
+ }
+ }
+}
+
+impl Command for DeleteSceneCommand {
+ fn execute(
+ &mut self,
+ project: &mut Project,
+ ) -> Result<Option<CommandResult>, CommandError> {
+ let Some(deleted) = project.scenes.iter().find(|scene| scene.id == self.scene_id)
+ else {
+ return Ok(None);
+ };
+ if deleted.is_main {
+ return Ok(None);
+ }
+
+ self.saved_scenes = Some(project.scenes.clone());
+ self.saved_current_scene_id = Some(project.current_scene_id.clone());
+
+ project.scenes.retain(|scene| scene.id != self.scene_id);
+
+ if project.current_scene_id == self.scene_id {
+ let fallback_id = project
+ .scenes
+ .iter()
+ .find(|scene| scene.is_main)
+ .map(|scene| scene.id.clone());
+ if let Some(fallback_id) = fallback_id {
+ project.current_scene_id = fallback_id;
+ }
+ }
+ Ok(None)
+ }
+
+ fn undo(&mut self, project: &mut Project) {
+ let (Some(saved_scenes), Some(saved_current_scene_id)) =
+ (&self.saved_scenes, &self.saved_current_scene_id)
+ else {
+ return;
+ };
+ project.scenes = saved_scenes.clone();
+ project.current_scene_id = saved_current_scene_id.clone();
+ }
+}
diff --git a/rust/crates/commands/src/scene/mod.rs b/rust/crates/commands/src/scene/mod.rs
new file mode 100644
index 00000000..10d687c1
--- /dev/null
+++ b/rust/crates/commands/src/scene/mod.rs
@@ -0,0 +1,15 @@
+pub mod create_scene;
+pub mod delete_scene;
+pub mod move_bookmark;
+pub mod remove_bookmark;
+pub mod rename_scene;
+pub mod toggle_bookmark;
+pub mod update_bookmark;
+
+pub use create_scene::CreateSceneCommand;
+pub use delete_scene::DeleteSceneCommand;
+pub use move_bookmark::MoveBookmarkCommand;
+pub use remove_bookmark::RemoveBookmarkCommand;
+pub use rename_scene::RenameSceneCommand;
+pub use toggle_bookmark::ToggleBookmarkCommand;
+pub use update_bookmark::UpdateBookmarkCommand;
diff --git a/rust/crates/commands/src/scene/move_bookmark.rs b/rust/crates/commands/src/scene/move_bookmark.rs
new file mode 100644
index 00000000..dddef11f
--- /dev/null
+++ b/rust/crates/commands/src/scene/move_bookmark.rs
@@ -0,0 +1,50 @@
+use scene::Project;
+use time::MediaTime;
+
+use crate::bookmarks::{get_frame_time, move_bookmark_in_array};
+use crate::command::{Command, CommandError, CommandResult};
+use crate::project_ext::active_scene_mut;
+
+pub struct MoveBookmarkCommand {
+ from_time: MediaTime,
+ to_time: MediaTime,
+ saved_bookmarks: Option<Vec<scene::Bookmark>>,
+}
+
+impl MoveBookmarkCommand {
+ pub fn new(from_time: MediaTime, to_time: MediaTime) -> Self {
+ Self {
+ from_time,
+ to_time,
+ saved_bookmarks: None,
+ }
+ }
+}
+
+impl Command for MoveBookmarkCommand {
+ fn execute(
+ &mut self,
+ project: &mut Project,
+ ) -> Result<Option<CommandResult>, CommandError> {
+ let fps = project.settings.fps;
+ let Some(scene) = active_scene_mut(project) else {
+ return Ok(None);
+ };
+
+ self.saved_bookmarks = Some(scene.bookmarks.clone());
+ let from_frame_time = get_frame_time(self.from_time, fps);
+ let to_frame_time = get_frame_time(self.to_time, fps);
+ scene.bookmarks =
+ move_bookmark_in_array(&scene.bookmarks, from_frame_time, to_frame_time);
+ Ok(None)
+ }
+
+ fn undo(&mut self, project: &mut Project) {
+ let Some(saved) = &self.saved_bookmarks else {
+ return;
+ };
+ if let Some(scene) = active_scene_mut(project) {
+ scene.bookmarks = saved.clone();
+ }
+ }
+}
diff --git a/rust/crates/commands/src/scene/remove_bookmark.rs b/rust/crates/commands/src/scene/remove_bookmark.rs
new file mode 100644
index 00000000..f8fe0c5a
--- /dev/null
+++ b/rust/crates/commands/src/scene/remove_bookmark.rs
@@ -0,0 +1,54 @@
+use scene::Project;
+use time::MediaTime;
+
+use crate::bookmarks::{get_frame_time, remove_bookmark_from_array};
+use crate::command::{Command, CommandError, CommandResult};
+use crate::project_ext::active_scene_mut;
+
+pub struct RemoveBookmarkCommand {
+ time: MediaTime,
+ frame_time: MediaTime,
+ saved_bookmarks: Option<Vec<scene::Bookmark>>,
+}
+
+impl RemoveBookmarkCommand {
+ pub fn new(time: MediaTime) -> Self {
+ Self {
+ time,
+ frame_time: MediaTime::ZERO,
+ saved_bookmarks: None,
+ }
+ }
+}
+
+impl Command for RemoveBookmarkCommand {
+ fn execute(
+ &mut self,
+ project: &mut Project,
+ ) -> Result<Option<CommandResult>, CommandError> {
+ let fps = project.settings.fps;
+ let Some(scene) = active_scene_mut(project) else {
+ return Ok(None);
+ };
+
+ self.saved_bookmarks = Some(scene.bookmarks.clone());
+ self.frame_time = get_frame_time(self.time, fps);
+
+ let updated = remove_bookmark_from_array(&scene.bookmarks, self.frame_time);
+ if updated.len() == scene.bookmarks.len() {
+ return Ok(None);
+ }
+
+ scene.bookmarks = updated;
+ Ok(None)
+ }
+
+ fn undo(&mut self, project: &mut Project) {
+ let Some(saved) = &self.saved_bookmarks else {
+ return;
+ };
+ if let Some(scene) = active_scene_mut(project) {
+ scene.bookmarks = saved.clone();
+ }
+ }
+}
diff --git a/rust/crates/commands/src/scene/rename_scene.rs b/rust/crates/commands/src/scene/rename_scene.rs
new file mode 100644
index 00000000..4876fb9b
--- /dev/null
+++ b/rust/crates/commands/src/scene/rename_scene.rs
@@ -0,0 +1,58 @@
+use scene::Project;
+
+use crate::command::{Command, CommandError, CommandResult};
+
+pub struct RenameSceneCommand {
+ scene_id: String,
+ new_name: String,
+ saved_name: Option<String>,
+ saved_updated_at: Option<chrono::DateTime<chrono::Utc>>,
+}
+
+impl RenameSceneCommand {
+ pub fn new(scene_id: impl Into<String>, new_name: impl Into<String>) -> Self {
+ Self {
+ scene_id: scene_id.into(),
+ new_name: new_name.into(),
+ saved_name: None,
+ saved_updated_at: None,
+ }
+ }
+}
+
+impl Command for RenameSceneCommand {
+ fn execute(
+ &mut self,
+ project: &mut Project,
+ ) -> Result<Option<CommandResult>, CommandError> {
+ let Some(scene) = project
+ .scenes
+ .iter_mut()
+ .find(|scene| scene.id == self.scene_id)
+ else {
+ return Ok(None);
+ };
+
+ self.saved_name = Some(scene.name.clone());
+ self.saved_updated_at = Some(scene.updated_at);
+ scene.name = self.new_name.clone();
+ scene.updated_at = chrono::Utc::now();
+ Ok(None)
+ }
+
+ fn undo(&mut self, project: &mut Project) {
+ let (Some(saved_name), Some(saved_updated_at)) =
+ (&self.saved_name, self.saved_updated_at)
+ else {
+ return;
+ };
+ if let Some(scene) = project
+ .scenes
+ .iter_mut()
+ .find(|scene| scene.id == self.scene_id)
+ {
+ scene.name = saved_name.clone();
+ scene.updated_at = saved_updated_at;
+ }
+ }
+}
diff --git a/rust/crates/commands/src/scene/toggle_bookmark.rs b/rust/crates/commands/src/scene/toggle_bookmark.rs
new file mode 100644
index 00000000..054e1c78
--- /dev/null
+++ b/rust/crates/commands/src/scene/toggle_bookmark.rs
@@ -0,0 +1,48 @@
+use scene::Project;
+
+use crate::bookmarks::{get_frame_time, toggle_bookmark_in_array};
+use crate::command::{Command, CommandError, CommandResult};
+use crate::project_ext::active_scene_mut;
+use time::MediaTime;
+
+pub struct ToggleBookmarkCommand {
+ time: MediaTime,
+ frame_time: MediaTime,
+ saved_bookmarks: Option<Vec<scene::Bookmark>>,
+}
+
+impl ToggleBookmarkCommand {
+ pub fn new(time: MediaTime) -> Self {
+ Self {
+ time,
+ frame_time: MediaTime::ZERO,
+ saved_bookmarks: None,
+ }
+ }
+}
+
+impl Command for ToggleBookmarkCommand {
+ fn execute(
+ &mut self,
+ project: &mut Project,
+ ) -> Result<Option<CommandResult>, CommandError> {
+ let fps = project.settings.fps;
+ let Some(scene) = active_scene_mut(project) else {
+ return Ok(None);
+ };
+
+ self.saved_bookmarks = Some(scene.bookmarks.clone());
+ self.frame_time = get_frame_time(self.time, fps);
+ scene.bookmarks = toggle_bookmark_in_array(&scene.bookmarks, self.frame_time);
+ Ok(None)
+ }
+
+ fn undo(&mut self, project: &mut Project) {
+ let Some(saved) = &self.saved_bookmarks else {
+ return;
+ };
+ if let Some(scene) = active_scene_mut(project) {
+ scene.bookmarks = saved.clone();
+ }
+ }
+}
diff --git a/rust/crates/commands/src/scene/update_bookmark.rs b/rust/crates/commands/src/scene/update_bookmark.rs
new file mode 100644
index 00000000..f2c9b94e
--- /dev/null
+++ b/rust/crates/commands/src/scene/update_bookmark.rs
@@ -0,0 +1,49 @@
+use scene::Project;
+use time::MediaTime;
+
+use crate::bookmarks::{BookmarkPatch, get_frame_time, update_bookmark_in_array};
+use crate::command::{Command, CommandError, CommandResult};
+use crate::project_ext::active_scene_mut;
+
+pub struct UpdateBookmarkCommand {
+ time: MediaTime,
+ updates: BookmarkPatch,
+ saved_bookmarks: Option<Vec<scene::Bookmark>>,
+}
+
+impl UpdateBookmarkCommand {
+ pub fn new(time: MediaTime, updates: BookmarkPatch) -> Self {
+ Self {
+ time,
+ updates,
+ saved_bookmarks: None,
+ }
+ }
+}
+
+impl Command for UpdateBookmarkCommand {
+ fn execute(
+ &mut self,
+ project: &mut Project,
+ ) -> Result<Option<CommandResult>, CommandError> {
+ let fps = project.settings.fps;
+ let Some(scene) = active_scene_mut(project) else {
+ return Ok(None);
+ };
+
+ self.saved_bookmarks = Some(scene.bookmarks.clone());
+ let frame_time = get_frame_time(self.time, fps);
+ scene.bookmarks =
+ update_bookmark_in_array(&scene.bookmarks, frame_time, &self.updates);
+ Ok(None)
+ }
+
+ fn undo(&mut self, project: &mut Project) {
+ let Some(saved) = &self.saved_bookmarks else {
+ return;
+ };
+ if let Some(scene) = active_scene_mut(project) {
+ scene.bookmarks = saved.clone();
+ }
+ }
+}
diff --git a/rust/crates/commands/src/timeline/element/delete_elements.rs b/rust/crates/commands/src/timeline/element/delete_elements.rs
new file mode 100644
index 00000000..bdcb5b6d
--- /dev/null
+++ b/rust/crates/commands/src/timeline/element/delete_elements.rs
@@ -0,0 +1,64 @@
+use scene::{ElementRef, Project, SceneTracks, Track};
+
+use crate::command::{Command, CommandError, CommandResult};
+use crate::project_ext::active_scene_mut;
+
+fn remove_track_elements(track: &mut Track, elements: &[ElementRef]) {
+ if !elements.iter().any(|target| target.track_id == track.id()) {
+ return;
+ }
+ track.elements_mut().retain(|element| {
+ !elements
+ .iter()
+ .any(|target| target.track_id == track.id() && target.element_id == element.base().id)
+ });
+}
+
+fn remove_elements_from_tracks(tracks: &mut SceneTracks, elements: &[ElementRef]) {
+ for track in &mut tracks.overlay {
+ remove_track_elements(track, elements);
+ }
+ remove_track_elements(&mut tracks.main, elements);
+ for track in &mut tracks.audio {
+ remove_track_elements(track, elements);
+ }
+}
+
+pub struct DeleteElementsCommand {
+ elements: Vec<ElementRef>,
+ saved_state: Option<SceneTracks>,
+}
+
+impl DeleteElementsCommand {
+ pub fn new(elements: Vec<ElementRef>) -> Self {
+ Self {
+ elements,
+ saved_state: None,
+ }
+ }
+}
+
+impl Command for DeleteElementsCommand {
+ fn execute(
+ &mut self,
+ project: &mut Project,
+ ) -> Result<Option<CommandResult>, CommandError> {
+ let Some(scene) = active_scene_mut(project) else {
+ return Ok(None);
+ };
+
+ self.saved_state = Some(scene.tracks.clone());
+ remove_elements_from_tracks(&mut scene.tracks, &self.elements);
+
+ Ok(Some(CommandResult::element_selection(Vec::new())))
+ }
+
+ fn undo(&mut self, project: &mut Project) {
+ let Some(saved) = &self.saved_state else {
+ return;
+ };
+ if let Some(scene) = active_scene_mut(project) {
+ scene.tracks = saved.clone();
+ }
+ }
+}
diff --git a/rust/crates/commands/src/timeline/element/mod.rs b/rust/crates/commands/src/timeline/element/mod.rs
new file mode 100644
index 00000000..2ba806e1
--- /dev/null
+++ b/rust/crates/commands/src/timeline/element/mod.rs
@@ -0,0 +1,3 @@
+pub mod delete_elements;
+
+pub use delete_elements::DeleteElementsCommand;
diff --git a/rust/crates/commands/src/timeline/element/move_elements.rs b/rust/crates/commands/src/timeline/element/move_elements.rs
new file mode 100644
index 00000000..fc6425d1
--- /dev/null
+++ b/rust/crates/commands/src/timeline/element/move_elements.rs
@@ -0,0 +1,145 @@
+use scene::{Element, Project, SceneTracks, Track};
+use time::MediaTime;
+use timeline::TrackType;
+use timeline::placement::build_empty_track;
+
+use crate::command::{Command, CommandError, CommandResult};
+use crate::project_ext::active_scene_mut;
+use crate::tracks::{all_tracks_mut, find_track};
+
+#[derive(Clone, Debug, PartialEq)]
+pub struct PlannedTrackCreation {
+ pub id: String,
+ pub track_type: TrackType,
+ pub index: usize,
+}
+
+#[derive(Clone, Debug, PartialEq)]
+pub struct PlannedElementMove {
+ pub source_track_id: String,
+ pub target_track_id: String,
+ pub element_id: String,
+ pub new_start_time: MediaTime,
+}
+
+fn insert_track_at_display_index(
+ tracks: &mut SceneTracks,
+ track: Track,
+ insert_index: usize,
+) {
+ if matches!(track, Track::Audio { .. }) {
+ let audio_insert_index = insert_index
+ .saturating_sub(tracks.overlay.len() + 1)
+ .min(tracks.audio.len());
+ tracks.audio.insert(audio_insert_index, track);
+ return;
+ }
+
+ let overlay_insert_index = insert_index.min(tracks.overlay.len());
+ tracks.overlay.insert(overlay_insert_index, track);
+}
+
+fn validate_compatibility(element: &Element, track: &Track) -> Result<(), CommandError> {
+ timeline::placement::validate_element_track_compatibility(element, track)
+ .map_err(CommandError::IncompatiblePlacement)
+}
+
+pub struct MoveElementCommand {
+ moves: Vec<PlannedElementMove>,
+ create_tracks: Vec<PlannedTrackCreation>,
+ saved_state: Option<SceneTracks>,
+}
+
+impl MoveElementCommand {
+ pub fn new(moves: Vec<PlannedElementMove>, create_tracks: Vec<PlannedTrackCreation>) -> Self {
+ Self {
+ moves,
+ create_tracks,
+ saved_state: None,
+ }
+ }
+}
+
+impl Command for MoveElementCommand {
+ fn execute(
+ &mut self,
+ project: &mut Project,
+ ) -> Result<Option<CommandResult>, CommandError> {
+ let Some(scene) = active_scene_mut(project) else {
+ return Ok(None);
+ };
+
+ let saved_state = scene.tracks.clone();
+ self.saved_state = Some(saved_state.clone());
+
+ let mut create_tracks = self.create_tracks.clone();
+ create_tracks.sort_by_key(|creation| creation.index);
+ for creation in &create_tracks {
+ let track = build_empty_track(&creation.id, creation.track_type, None);
+ insert_track_at_display_index(&mut scene.tracks, track, creation.index);
+ }
+
+ let mut moved_elements = Vec::new();
+ for planned_move in &self.moves {
+ let source_track = find_track(&saved_state, &planned_move.source_track_id);
+ let source_element = source_track.and_then(|track| {
+ track
+ .elements()
+ .iter()
+ .find(|element| element.base().id == planned_move.element_id)
+ });
+ let Some(source_element) = source_element else {
+ return Err(CommandError::SourceNotFound);
+ };
+
+ let Some(target_track) = find_track(&scene.tracks, &planned_move.target_track_id)
+ else {
+ return Err(CommandError::TargetTrackNotFound);
+ };
+
+ validate_compatibility(source_element, target_track)?;
+
+ let mut moved = source_element.clone();
+ moved.base_mut().start_time = planned_move.new_start_time;
+ moved_elements.push((planned_move.target_track_id.clone(), moved));
+ }
+
+ let moved_ids: Vec<&str> = self
+ .moves
+ .iter()
+ .map(|planned_move| planned_move.element_id.as_str())
+ .collect();
+
+ for track in all_tracks_mut(&mut scene.tracks) {
+ let track_id = track.id().to_string();
+ let mut elements = std::mem::take(track.elements_mut());
+ elements.retain(|element| !moved_ids.contains(&element.base().id.as_str()));
+ elements.extend(
+ moved_elements
+ .iter()
+ .filter(|(target_track_id, _)| *target_track_id == track_id)
+ .map(|(_, element)| element.clone()),
+ );
+ *track.elements_mut() = elements;
+ }
+
+ Ok(Some(CommandResult::element_selection(
+ self.moves
+ .iter()
+ .map(|planned_move| scene::ElementRef {
+ track_id: planned_move.target_track_id.clone(),
+ element_id: planned_move.element_id.clone(),
+ })
+ .collect(),
+ )))
+ }
+
+ fn undo(&mut self, project: &mut Project) {
+ let Some(saved) = &self.saved_state else {
+ return;
+ };
+ if let Some(scene) = active_scene_mut(project) {
+ scene.tracks = saved.clone();
+ }
+ }
+}
diff --git a/rust/crates/commands/src/timeline/element/shift_split_remainder.rs b/rust/crates/commands/src/timeline/element/shift_split_remainder.rs
new file mode 100644
index 00000000..be2f78e4
--- /dev/null
+++ b/rust/crates/commands/src/timeline/element/shift_split_remainder.rs
@@ -0,0 +1,58 @@
+use std::cell::RefCell;
+use std::rc::Rc;
+
+use scene::{ElementRef, Project};
+use time::MediaTime;
+
+use crate::command::{Command, CommandError, CommandResult};
+use crate::timeline::element::move_elements::{MoveElementCommand, PlannedElementMove};
+
+pub struct ShiftSplitRemainderCommand {
+ right_side: Rc<RefCell<Vec<ElementRef>>>,
+ new_start_time: MediaTime,
+ inner: Option<MoveElementCommand>,
+}
+
+impl ShiftSplitRemainderCommand {
+ pub fn new(right_side: Rc<RefCell<Vec<ElementRef>>>, new_start_time: MediaTime) -> Self {
+ Self {
+ right_side,
+ new_start_time,
+ inner: None,
+ }
+ }
+}
+
+impl Command for ShiftSplitRemainderCommand {
+ fn execute(
+ &mut self,
+ project: &mut Project,
+ ) -> Result<Option<CommandResult>, CommandError> {
+ let right_side = self.right_side.borrow().clone();
+ if right_side.is_empty() {
+ return Ok(None);
+ }
+
+ let mut inner = MoveElementCommand::new(
+ right_side
+ .iter()
+ .map(|element| PlannedElementMove {
+ source_track_id: element.track_id.clone(),
+ target_track_id: element.track_id.clone(),
+ element_id: element.element_id.clone(),
+ new_start_time: self.new_start_time,
+ })
+ .collect(),
+ Vec::new(),
+ );
+ let result = inner.execute(project)?;
+ self.inner = Some(inner);
+ Ok(result)
+ }
+
+ fn undo(&mut self, project: &mut Project) {
+ if let Some(inner) = &mut self.inner {
+ inner.undo(project);
+ }
+ }
+}
diff --git a/rust/crates/commands/src/timeline/element/split_elements.rs b/rust/crates/commands/src/timeline/element/split_elements.rs
new file mode 100644
index 00000000..0da90792
--- /dev/null
+++ b/rust/crates/commands/src/timeline/element/split_elements.rs
@@ -0,0 +1,225 @@
+use std::cell::RefCell;
+use std::rc::Rc;
+
+use scene::{AudioElement, Element, ElementRef, Project, RetimeConfig, SceneTracks};
+use time::MediaTime;
+use uuid::Uuid;
+
+use crate::command::{Command, CommandError, CommandResult};
+use crate::project_ext::active_scene_mut;
+use crate::tracks::all_tracks_mut;
+
+const MIN_RETIME_RATE: f64 = 0.01;
+const MAX_RETIME_RATE: f64 = 5.0;
+const DEFAULT_RETIME_RATE: f64 = 1.0;
+
+#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
+pub enum RetainSide {
+ #[default]
+ Both,
+ Left,
+ Right,
+}
+
+fn clamp_retime_rate(rate: f64) -> f64 {
+ if !rate.is_finite() || rate <= 0.0 {
+ return DEFAULT_RETIME_RATE;
+ }
+ rate.clamp(MIN_RETIME_RATE, MAX_RETIME_RATE)
+}
+
+fn source_span_at_clip_time(clip_time: MediaTime, retime: Option<RetimeConfig>) -> f64 {
+ let rate = clamp_retime_rate(retime.map(|config| config.rate).unwrap_or(1.0));
+ (clip_time.as_ticks() as f64 * rate).max(0.0)
+}
+
+fn round_to_ticks(time: f64) -> MediaTime {
+ MediaTime::from_ticks(time.round() as i64)
+}
+
+fn retime_of(element: &Element) -> Option<RetimeConfig> {
+ match element {
+ Element::Video(video) => video.retime,
+ Element::Audio(AudioElement::Upload(upload)) => upload.retime,
+ Element::Audio(AudioElement::Library(library)) => library.retime,
+ _ => None,
+ }
+}
+
+pub struct SplitElementsCommand {
+ elements: Vec<ElementRef>,
+ split_time: MediaTime,
+ retain_side: RetainSide,
+ saved_state: Option<SceneTracks>,
+ right_side: Rc<RefCell<Vec<ElementRef>>>,
+}
+
+impl SplitElementsCommand {
+ pub fn new(elements: Vec<ElementRef>, split_time: MediaTime, retain_side: RetainSide) -> Self {
+ Self {
+ elements,
+ split_time,
+ retain_side,
+ saved_state: None,
+ right_side: Rc::new(RefCell::new(Vec::new())),
+ }
+ }
+
+ pub fn right_side_handle(&self) -> Rc<RefCell<Vec<ElementRef>>> {
+ Rc::clone(&self.right_side)
+ }
+
+ pub fn right_side_elements(&self) -> Vec<ElementRef> {
+ self.right_side.borrow().clone()
+ }
+
+ fn split_element(
+ &self,
+ track_id: &str,
+ element: Element,
+ right_side: &mut Vec<ElementRef>,
+ ) -> Vec<Element> {
+ let start = element.base().start_time;
+ let end = start + element.base().duration;
+
+ if self.split_time <= start || self.split_time >= end {
+ return vec![element];
+ }
+
+ let relative_time = self.split_time - start;
+ let left_visible_duration = relative_time;
+ let right_visible_duration = element.base().duration - relative_time;
+ let retime = retime_of(&element);
+
+ let left_source_span = round_to_ticks(source_span_at_clip_time(
+ left_visible_duration,
+ retime,
+ ));
+ let total_source_span =
+ round_to_ticks(source_span_at_clip_time(element.base().duration, retime));
+ let right_source_span = total_source_span - left_source_span;
+
+ let (left_animations, right_animations) = animation::split_animations_at_time_with_options(
+ element.base().animations.as_ref(),
+ relative_time,
+ true,
+ );
+
+ let left_trim_end = element.base().trim_end + right_source_span;
+ let right_trim_start = element.base().trim_start + left_source_span;
+
+ match self.retain_side {
+ RetainSide::Left => {
+ let mut left = element;
+ {
+ let base = left.base_mut();
+ base.duration = left_visible_duration;
+ base.trim_end = left_trim_end;
+ base.name = format!("{} (left)", base.name);
+ base.animations = left_animations;
+ }
+ vec![left]
+ }
+ RetainSide::Right => {
+ let mut right = element;
+ let new_id = Uuid::new_v4().to_string();
+ right_side.push(ElementRef {
+ track_id: track_id.to_string(),
+ element_id: new_id.clone(),
+ });
+ {
+ let base = right.base_mut();
+ base.id = new_id;
+ base.start_time = self.split_time;
+ base.duration = right_visible_duration;
+ base.trim_start = right_trim_start;
+ base.name = format!("{} (right)", base.name);
+ base.animations = right_animations;
+ }
+ vec![right]
+ }
+ RetainSide::Both => {
+ let mut left = element;
+ let mut right = left.clone();
+ let right_id = Uuid::new_v4().to_string();
+ right_side.push(ElementRef {
+ track_id: track_id.to_string(),
+ element_id: right_id.clone(),
+ });
+ {
+ let base = left.base_mut();
+ base.duration = left_visible_duration;
+ base.trim_end = left_trim_end;
+ base.name = format!("{} (left)", base.name);
+ base.animations = left_animations;
+ }
+ {
+ let base = right.base_mut();
+ base.id = right_id;
+ base.start_time = self.split_time;
+ base.duration = right_visible_duration;
+ base.trim_start = right_trim_start;
+ base.name = format!("{} (right)", base.name);
+ base.animations = right_animations;
+ }
+ vec![left, right]
+ }
+ }
+ }
+}
+
+impl Command for SplitElementsCommand {
+ fn execute(
+ &mut self,
+ project: &mut Project,
+ ) -> Result<Option<CommandResult>, CommandError> {
+ let Some(scene) = active_scene_mut(project) else {
+ return Ok(None);
+ };
+
+ self.saved_state = Some(scene.tracks.clone());
+ self.right_side.borrow_mut().clear();
+
+ for track in all_tracks_mut(&mut scene.tracks) {
+ let targets: Vec<&str> = self
+ .elements
+ .iter()
+ .filter(|target| target.track_id == track.id())
+ .map(|target| target.element_id.as_str())
+ .collect();
+ if targets.is_empty() {
+ continue;
+ }
+
+ let track_id = track.id().to_string();
+ let previous = std::mem::take(track.elements_mut());
+ let mut next = Vec::with_capacity(previous.len());
+ {
+ let mut right_side = self.right_side.borrow_mut();
+ for element in previous {
+ if !targets.contains(&element.base().id.as_str()) {
+ next.push(element);
+ continue;
+ }
+ next.extend(self.split_element(&track_id, element, &mut right_side));
+ }
+ }
+ *track.elements_mut() = next;
+ }
+
+ let right_side = self.right_side.borrow();
+ if right_side.is_empty() {
+ return Ok(None);
+ }
+ Ok(Some(CommandResult::element_selection(right_side.clone())))
+ }
+
+ fn undo(&mut self, project: &mut Project) {
+ let Some(saved) = &self.saved_state else {
+ return;
+ };
+ if let Some(scene) = active_scene_mut(project) {
+ scene.tracks = saved.clone();
+ }
+ }
+}
diff --git a/rust/crates/commands/src/timeline/element/update_elements.rs b/rust/crates/commands/src/timeline/element/update_elements.rs
new file mode 100644
index 00000000..f5931bb7
--- /dev/null
+++ b/rust/crates/commands/src/timeline/element/update_elements.rs
@@ -0,0 +1,72 @@
+use scene::{ElementRef, Project, SceneTracks};
+use timeline::update_pipeline::{ElementPatch, ElementUpdateContext, apply_element_update};
+
+use crate::command::{Command, CommandError, CommandResult};
+use crate::project_ext::active_scene_mut;
+use crate::tracks::find_track_mut;
+
+#[derive(Clone, Debug, PartialEq)]
+pub struct ElementUpdate {
+ pub track_id: String,
+ pub element_id: String,
+ pub patch: ElementPatch,
+}
+
+pub struct UpdateElementsCommand {
+ updates: Vec<ElementUpdate>,
+ saved_state: Option<SceneTracks>,
+}
+
+impl UpdateElementsCommand {
+ pub fn new(updates: Vec<ElementUpdate>) -> Self {
+ Self {
+ updates,
+ saved_state: None,
+ }
+ }
+}
+
+impl Command for UpdateElementsCommand {
+ fn execute(
+ &mut self,
+ project: &mut Project,
+ ) -> Result<Option<CommandResult>, CommandError> {
+ let Some(scene) = active_scene_mut(project) else {
+ return Ok(None);
+ };
+
+ self.saved_state = Some(scene.tracks.clone());
+
+ for update in &self.updates {
+ let context_tracks = scene.tracks.clone();
+ let context = ElementUpdateContext {
+ tracks: &context_tracks,
+ track_id: &update.track_id,
+ };
+ let Some(track) = find_track_mut(&mut scene.tracks, &update.track_id) else {
+ continue;
+ };
+ let Some(element) = track
+ .elements_mut()
+ .iter_mut()
+ .find(|element| element.base().id == update.element_id)
+ else {
+ continue;
+ };
+
+ let current = element.clone();
+ *element = apply_element_update(current, update.patch.clone(), &context);
+ }
+
+ Ok(None)
+ }
+
+ fn undo(&mut self, project: &mut Project) {
+ let Some(saved) = &self.saved_state else {
+ return;
+ };
+ if let Some(scene) = active_scene_mut(project) {
+ scene.tracks = saved.clone();
+ }
+ }
+}
diff --git a/rust/crates/commands/src/timeline/mod.rs b/rust/crates/commands/src/timeline/mod.rs
new file mode 100644
index 00000000..532885ce
--- /dev/null
+++ b/rust/crates/commands/src/timeline/mod.rs
@@ -0,0 +1,5 @@
+pub mod element;
+pub mod track;
+pub mod tracks_snapshot;
+
+pub use tracks_snapshot::TracksSnapshotCommand;
diff --git a/rust/crates/commands/src/timeline/track/add_track.rs b/rust/crates/commands/src/timeline/track/add_track.rs
new file mode 100644
index 00000000..d6e9bbae
--- /dev/null
+++ b/rust/crates/commands/src/timeline/track/add_track.rs
@@ -0,0 +1,69 @@
+use scene::{Project, SceneTracks};
+use timeline::placement::{build_empty_track, get_default_insert_index_for_track};
+use timeline::TrackType;
+use uuid::Uuid;
+
+use crate::command::{Command, CommandError, CommandResult};
+use crate::project_ext::active_scene_mut;
+
+pub struct AddTrackCommand {
+ track_type: TrackType,
+ index: Option<usize>,
+ track_id: String,
+ saved_state: Option<SceneTracks>,
+}
+
+impl AddTrackCommand {
+ pub fn new(track_type: TrackType, index: Option<usize>) -> Self {
+ Self {
+ track_type,
+ index,
+ track_id: Uuid::new_v4().to_string(),
+ saved_state: None,
+ }
+ }
+
+ pub fn track_id(&self) -> &str {
+ &self.track_id
+ }
+}
+
+impl Command for AddTrackCommand {
+ fn execute(
+ &mut self,
+ project: &mut Project,
+ ) -> Result<Option<CommandResult>, CommandError> {
+ let Some(scene) = active_scene_mut(project) else {
+ return Ok(None);
+ };
+
+ self.saved_state = Some(scene.tracks.clone());
+
+ let insert_index = self.index.unwrap_or_else(|| {
+ get_default_insert_index_for_track(&scene.tracks, self.track_type)
+ });
+
+ let new_track = build_empty_track(&self.track_id, self.track_type, None);
+ if self.track_type == TrackType::Audio {
+ let audio_insert_index = insert_index
+ .saturating_sub(scene.tracks.overlay.len() + 1);
+ scene
+ .tracks
+ .audio
+ .insert(audio_insert_index.min(scene.tracks.audio.len()), new_track);
+ } else {
+ let overlay_insert_index = insert_index.min(scene.tracks.overlay.len());
+ scene.tracks.overlay.insert(overlay_insert_index, new_track);
+ }
+ Ok(None)
+ }
+
+ fn undo(&mut self, project: &mut Project) {
+ let Some(saved) = &self.saved_state else {
+ return;
+ };
+ if let Some(scene) = active_scene_mut(project) {
+ scene.tracks = saved.clone();
+ }
+ }
+}
diff --git a/rust/crates/commands/src/timeline/track/mod.rs b/rust/crates/commands/src/timeline/track/mod.rs
new file mode 100644
index 00000000..c133a12d
--- /dev/null
+++ b/rust/crates/commands/src/timeline/track/mod.rs
@@ -0,0 +1,9 @@
+pub mod add_track;
+pub mod remove_track;
+pub mod toggle_track_mute;
+pub mod toggle_track_visibility;
+
+pub use add_track::AddTrackCommand;
+pub use remove_track::RemoveTrackCommand;
+pub use toggle_track_mute::ToggleTrackMuteCommand;
+pub use toggle_track_visibility::ToggleTrackVisibilityCommand;
diff --git a/rust/crates/commands/src/timeline/track/remove_track.rs b/rust/crates/commands/src/timeline/track/remove_track.rs
new file mode 100644
index 00000000..5a79b4ae
--- /dev/null
+++ b/rust/crates/commands/src/timeline/track/remove_track.rs
@@ -0,0 +1,43 @@
+use scene::{Project, SceneTracks};
+
+use crate::command::{Command, CommandError, CommandResult};
+use crate::project_ext::active_scene_mut;
+
+pub struct RemoveTrackCommand {
+ track_id: String,
+ saved_state: Option<SceneTracks>,
+}
+
+impl RemoveTrackCommand {
+ pub fn new(track_id: impl Into<String>) -> Self {
+ Self {
+ track_id: track_id.into(),
+ saved_state: None,
+ }
+ }
+}
+
+impl Command for RemoveTrackCommand {
+ fn execute(
+ &mut self,
+ project: &mut Project,
+ ) -> Result<Option<CommandResult>, CommandError> {
+ let Some(scene) = active_scene_mut(project) else {
+ return Ok(None);
+ };
+
+ self.saved_state = Some(scene.tracks.clone());
+ scene.tracks.overlay.retain(|track| track.id() != self.track_id);
+ scene.tracks.audio.retain(|track| track.id() != self.track_id);
+ Ok(None)
+ }
+
+ fn undo(&mut self, project: &mut Project) {
+ let Some(saved) = &self.saved_state else {
+ return;
+ };
+ if let Some(scene) = active_scene_mut(project) {
+ scene.tracks = saved.clone();
+ }
+ }
+}
diff --git a/rust/crates/commands/src/timeline/track/toggle_track_mute.rs b/rust/crates/commands/src/timeline/track/toggle_track_mute.rs
new file mode 100644
index 00000000..afaaf3f3
--- /dev/null
+++ b/rust/crates/commands/src/timeline/track/toggle_track_mute.rs
@@ -0,0 +1,49 @@
+use scene::{Project, SceneTracks, Track};
+
+use crate::command::{Command, CommandError, CommandResult};
+use crate::project_ext::active_scene_mut;
+use crate::tracks::find_track_mut;
+
+pub struct ToggleTrackMuteCommand {
+ track_id: String,
+ saved_state: Option<SceneTracks>,
+}
+
+impl ToggleTrackMuteCommand {
+ pub fn new(track_id: impl Into<String>) -> Self {
+ Self {
+ track_id: track_id.into(),
+ saved_state: None,
+ }
+ }
+}
+
+impl Command for ToggleTrackMuteCommand {
+ fn execute(
+ &mut self,
+ project: &mut Project,
+ ) -> Result<Option<CommandResult>, CommandError> {
+ let Some(scene) = active_scene_mut(project) else {
+ return Ok(None);
+ };
+
+ self.saved_state = Some(scene.tracks.clone());
+
+ match find_track_mut(&mut scene.tracks, &self.track_id) {
+ Some(Track::Video { muted, .. }) | Some(Track::Audio { muted, .. }) => {
+ *muted = !*muted;
+ }
+ _ => {}
+ }
+ Ok(None)
+ }
+
+ fn undo(&mut self, project: &mut Project) {
+ let Some(saved) = &self.saved_state else {
+ return;
+ };
+ if let Some(scene) = active_scene_mut(project) {
+ scene.tracks = saved.clone();
+ }
+ }
+}
diff --git a/rust/crates/commands/src/timeline/track/toggle_track_visibility.rs b/rust/crates/commands/src/timeline/track/toggle_track_visibility.rs
new file mode 100644
index 00000000..79000a73
--- /dev/null
+++ b/rust/crates/commands/src/timeline/track/toggle_track_visibility.rs
@@ -0,0 +1,54 @@
+use scene::{Project, SceneTracks, Track};
+
+use crate::command::{Command, CommandError, CommandResult};
+use crate::project_ext::active_scene_mut;
+use crate::tracks::find_track_mut;
+
+pub struct ToggleTrackVisibilityCommand {
+ track_id: String,
+ saved_state: Option<SceneTracks>,
+}
+
+impl ToggleTrackVisibilityCommand {
+ pub fn new(track_id: impl Into<String>) -> Self {
+ Self {
+ track_id: track_id.into(),
+ saved_state: None,
+ }
+ }
+}
+
+impl Command for ToggleTrackVisibilityCommand {
+ fn execute(
+ &mut self,
+ project: &mut Project,
+ ) -> Result<Option<CommandResult>, CommandError> {
+ let Some(scene) = active_scene_mut(project) else {
+ return Ok(None);
+ };
+
+ self.saved_state = Some(scene.tracks.clone());
+
+ match find_track_mut(&mut scene.tracks, &self.track_id) {
+ Some(
+ Track::Video { hidden, .. }
+ | Track::Text { hidden, .. }
+ | Track::Graphic { hidden, .. }
+ | Track::Effect { hidden, .. },
+ ) => {
+ *hidden = !*hidden;
+ }
+ _ => {}
+ }
+ Ok(None)
+ }
+
+ fn undo(&mut self, project: &mut Project) {
+ let Some(saved) = &self.saved_state else {
+ return;
+ };
+ if let Some(scene) = active_scene_mut(project) {
+ scene.tracks = saved.clone();
+ }
+ }
+}
diff --git a/rust/crates/commands/src/timeline/tracks_snapshot.rs b/rust/crates/commands/src/timeline/tracks_snapshot.rs
new file mode 100644
index 00000000..5108e123
--- /dev/null
+++ b/rust/crates/commands/src/timeline/tracks_snapshot.rs
@@ -0,0 +1,33 @@
+use scene::{Project, SceneTracks};
+
+use crate::command::{Command, CommandError, CommandResult};
+use crate::project_ext::active_scene_mut;
+
+pub struct TracksSnapshotCommand {
+ before: SceneTracks,
+ after: SceneTracks,
+}
+
+impl TracksSnapshotCommand {
+ pub fn new(before: SceneTracks, after: SceneTracks) -> Self {
+ Self { before, after }
+ }
+}
+
+impl Command for TracksSnapshotCommand {
+ fn execute(
+ &mut self,
+ project: &mut Project,
+ ) -> Result<Option<CommandResult>, CommandError> {
+ if let Some(scene) = active_scene_mut(project) {
+ scene.tracks = self.after.clone();
+ }
+ Ok(None)
+ }
+
+ fn undo(&mut self, project: &mut Project) {
+ if let Some(scene) = active_scene_mut(project) {
+ scene.tracks = self.before.clone();
+ }
+ }
+}
diff --git a/rust/crates/commands/src/tracks.rs b/rust/crates/commands/src/tracks.rs
new file mode 100644
index 00000000..332a5e6b
--- /dev/null
+++ b/rust/crates/commands/src/tracks.rs
@@ -0,0 +1,42 @@
+use scene::{SceneTracks, Track};
+
+pub fn find_track<'a>(tracks: &'a SceneTracks, track_id: &str) -> Option<&'a Track> {
+ if tracks.main.id() == track_id {
+ return Some(&tracks.main);
+ }
+ tracks
+ .overlay
+ .iter()
+ .find(|track| track.id() == track_id)
+ .or_else(|| tracks.audio.iter().find(|track| track.id() == track_id))
+}
+
+pub fn find_track_mut<'a>(
+ tracks: &'a mut SceneTracks,
+ track_id: &str,
+) -> Option<&'a mut Track> {
+ if tracks.main.id() == track_id {
+ return Some(&mut tracks.main);
+ }
+ tracks
+ .overlay
+ .iter_mut()
+ .find(|track| track.id() == track_id)
+ .or_else(|| tracks.audio.iter_mut().find(|track| track.id() == track_id))
+}
+
+pub fn all_tracks(tracks: &SceneTracks) -> impl Iterator<Item = &Track> {
+ tracks
+ .overlay
+ .iter()
+ .chain(std::iter::once(&tracks.main))
+ .chain(tracks.audio.iter())
+}
+
+pub fn all_tracks_mut(tracks: &mut SceneTracks) -> impl Iterator<Item = &mut Track> {
+ tracks
+ .overlay
+ .iter_mut()
+ .chain(std::iter::once(&mut tracks.main))
+ .chain(tracks.audio.iter_mut())
+}
diff --git a/rust/crates/compositor/tests/offscreen_render.rs b/rust/crates/compositor/tests/offscreen_render.rs
new file mode 100755
index 00000000..a3c6df10
--- /dev/null
+++ b/rust/crates/compositor/tests/offscreen_render.rs
@@ -0,0 +1,96 @@
+#![cfg(not(target_arch = "wasm32"))]
+
+use compositor::{
+ CanvasClearDescriptor, Compositor, FrameDescriptor, FrameItemDescriptor, LayerDescriptor,
+ QuadTransformDescriptor,
+};
+use gpu::GpuContext;
+
+fn block_on<F: Future>(future: F) -> F::Output {
+ use std::sync::Arc;
+ use std::task::{Context, Poll, Wake, Waker};
+
+ struct ThreadWaker(std::thread::Thread);
+
+ impl Wake for ThreadWaker {
+ fn wake(self: Arc<Self>) {
+ self.0.unpark();
+ }
+ }
+
+ let waker = Waker::from(Arc::new(ThreadWaker(std::thread::current())));
+ let mut context = Context::from_waker(&waker);
+ let mut future = Box::pin(future);
+ loop {
+ match future.as_mut().poll(&mut context) {
+ Poll::Ready(output) => return output,
+ Poll::Pending => std::thread::park(),
+ }
+ }
+}
+
+#[test]
+fn renders_solid_layer_offscreen() {
+ let Some(context) = block_on(GpuContext::new()).ok() else {
+ eprintln!("skipping: no GPU adapter available");
+ return;
+ };
+ let mut compositor = Compositor::new(&context);
+
+ let width = 64;
+ let height = 64;
+ let red = [255u8, 0, 0, 255];
+ let pixels: Vec<u8> = red.repeat((width * height) as usize);
+ let texture = context.create_render_texture(width, height, "test-source");
+ context.queue().write_texture(
+ texture.as_image_copy(),
+ &pixels,
+ gpu::wgpu::TexelCopyBufferLayout {
+ offset: 0,
+ bytes_per_row: Some(width * 4),
+ rows_per_image: Some(height),
+ },
+ texture.size(),
+ );
+ compositor.upsert_texture("source".to_string(), texture);
+
+ let frame = FrameDescriptor {
+ width,
+ height,
+ clear: CanvasClearDescriptor {
+ color: [0.0, 0.0, 0.0, 1.0],
+ },
+ items: vec![FrameItemDescriptor::Layer(LayerDescriptor {
+ texture_id: "source".to_string(),
+ transform: QuadTransformDescriptor {
+ center_x: 32.0,
+ center_y: 32.0,
+ width: width as f32,
+ height: height as f32,
+ rotation_degrees: 0.0,
+ flip_x: false,
+ flip_y: false,
+ },
+ opacity: 1.0,
+ blend_mode: compositor::BlendMode::Normal,
+ effect_pass_groups: Vec::new(),
+ mask: None,
+ })],
+ };
+
+ let output = compositor
+ .render_frame_to_texture(&context, &frame)
+ .expect("render");
+ let rendered = block_on(context.read_texture(&output)).expect("readback");
+ assert_eq!(rendered.len(), (width * height * 4) as usize);
+
+ let red_count = rendered
+ .chunks_exact(4)
+ .filter(|px| px[0] > 200 && px[1] < 60 && px[2] < 60)
+ .count();
+ let total = (width * height) as usize;
+ assert!(
+ red_count * 100 / total > 90,
+ "expected mostly red frame, got {red_count}/{total} red pixels"
+ );
+}
diff --git a/rust/crates/effect-defs/Cargo.toml b/rust/crates/effect-defs/Cargo.toml
new file mode 100644
index 00000000..26872649
--- /dev/null
+++ b/rust/crates/effect-defs/Cargo.toml
@@ -0,0 +1,9 @@
+[package]
+name = "effect-defs"
+version = "0.1.0"
+edition = "2024"
+
+[dependencies]
+scene = { version = "0.1.0", path = "../scene" }
+effects = { version = "0.1.0", path = "../effects" }
+thiserror = "2"
diff --git a/rust/crates/effect-defs/src/lib.rs b/rust/crates/effect-defs/src/lib.rs
new file mode 100644
index 00000000..e326a1a9
--- /dev/null
+++ b/rust/crates/effect-defs/src/lib.rs
@@ -0,0 +1,64 @@
+mod definitions;
+mod math;
+mod params;
+mod registry;
+mod types;
+
+use effects::EffectPass;
+use scene::ParamValues;
+use thiserror::Error;
+
+pub use definitions::blur::{
+ GAUSSIAN_BLUR_SHADER, INTENSITY_TO_SIGMA_DIVISOR, blur_effect_definition,
+ build_gaussian_blur_passes, intensity_to_sigma, parse_intensity,
+};
+pub use definitions::register_default_effects;
+pub use params::{
+ BooleanParamDefinition, ColorParamDefinition, FontParamDefinition, NumberParamDefinition,
+ ParamDefinition, ParamDependency, SelectOption, SelectParamDefinition, TextParamDefinition,
+ build_default_param_values,
+};
+pub use registry::EffectsRegistry;
+pub use types::{EffectDefinition, EffectPassTemplate, EffectRendererConfig, PassContext};
+
+#[derive(Debug, Error, PartialEq)]
+pub enum EffectDefsError {
+ #[error("Unknown effect: {effect_type}")]
+ UnknownEffect { effect_type: String },
+}
+
+pub fn resolve_effect_passes(
+ definition: &EffectDefinition,
+ effect_params: &ParamValues,
+ width: u32,
+ height: u32,
+) -> Vec<EffectPass> {
+ let context = PassContext {
+ effect_params,
+ width,
+ height,
+ };
+ if let Some(build_passes) = definition.renderer.build_passes {
+ return build_passes(&context);
+ }
+ definition
+ .renderer
+ .passes
+ .iter()
+ .map(|pass| EffectPass {
+ shader: pass.shader.to_string(),
+ uniforms: (pass.uniforms)(&context),
+ })
+ .collect()
+}
+
+pub fn params_with_defaults(
+ definition: &EffectDefinition,
+ effect_params: &ParamValues,
+) -> ParamValues {
+ let mut values = build_default_param_values(&definition.params);
+ for (key, value) in effect_params {
+ values.insert(key.clone(), value.clone());
+ }
+ values
+}
diff --git a/rust/crates/effect-defs/src/math.rs b/rust/crates/effect-defs/src/math.rs
new file mode 100644
index 00000000..dafc281e
--- /dev/null
+++ b/rust/crates/effect-defs/src/math.rs
@@ -0,0 +1,57 @@
+pub(crate) fn js_max(a: f64, b: f64) -> f64 {
+ if a.is_nan() || b.is_nan() { f64::NAN } else { a.max(b) }
+}
+
+pub(crate) fn js_min(a: f64, b: f64) -> f64 {
+ if a.is_nan() || b.is_nan() { f64::NAN } else { a.min(b) }
+}
+
+pub(crate) fn js_parse_float(value: &str) -> f64 {
+ let trimmed = value.trim_start();
+ let (sign, rest) = match trimmed.strip_prefix('-') {
+ Some(rest) => (-1.0, rest),
+ None => (1.0, trimmed.strip_prefix('+').unwrap_or(trimmed)),
+ };
+ if rest.starts_with("Infinity") {
+ return sign * f64::INFINITY;
+ }
+ let bytes = rest.as_bytes();
+ let mut end = 0;
+ let mut seen_digit = false;
+ let mut seen_dot = false;
+ while end < bytes.len() {
+ match bytes[end] {
+ b'0'..=b'9' => {
+ seen_digit = true;
+ end += 1;
+ }
+ b'.' if !seen_dot => {
+ seen_dot = true;
+ end += 1;
+ }
+ _ => break,
+ }
+ }
+ if !seen_digit {
+ return f64::NAN;
+ }
+ let mantissa_end = end;
+ if end < bytes.len() && (bytes[end] == b'e' || bytes[end] == b'E') {
+ let mut exponent_end = end + 1;
+ if exponent_end < bytes.len()
+ && (bytes[exponent_end] == b'+' || bytes[exponent_end] == b'-')
+ {
+ exponent_end += 1;
+ }
+ let digits_start = exponent_end;
+ while exponent_end < bytes.len() && bytes[exponent_end].is_ascii_digit() {
+ exponent_end += 1;
+ }
+ if exponent_end > digits_start {
+ end = exponent_end;
+ } else {
+ end = mantissa_end;
+ }
+ }
+ sign * rest[..end].parse::<f64>().unwrap_or(f64::NAN)
+}
diff --git a/rust/crates/effect-defs/src/params.rs b/rust/crates/effect-defs/src/params.rs
new file mode 100644
index 00000000..cc7c43dc
--- /dev/null
+++ b/rust/crates/effect-defs/src/params.rs
@@ -0,0 +1,165 @@
+use scene::params::{ParamValue, ParamValues};
+
+#[derive(Clone, Debug, PartialEq)]
+pub struct ParamDependency {
+ pub param: String,
+ pub equals: ParamValue,
+}
+
+#[derive(Clone, Debug, PartialEq)]
+pub struct SelectOption {
+ pub value: String,
+ pub label: String,
+}
+
+#[derive(Clone, Debug, PartialEq)]
+pub struct NumberParamDefinition {
+ pub key: String,
+ pub label: String,
+ pub default_value: f64,
+ pub min: f64,
+ pub max: Option<f64>,
+ pub step: f64,
+ pub group: Option<String>,
+ pub keyframable: Option<bool>,
+ pub dependencies: Vec<ParamDependency>,
+ pub display_multiplier: Option<f64>,
+ pub unit: Option<String>,
+ pub short_label: Option<String>,
+}
+
+impl NumberParamDefinition {
+ pub fn new(
+ key: &str,
+ label: &str,
+ default_value: f64,
+ min: f64,
+ max: Option<f64>,
+ step: f64,
+ ) -> Self {
+ Self {
+ key: key.to_string(),
+ label: label.to_string(),
+ default_value,
+ min,
+ max,
+ step,
+ group: None,
+ keyframable: None,
+ dependencies: Vec::new(),
+ display_multiplier: None,
+ unit: None,
+ short_label: None,
+ }
+ }
+}
+
+#[derive(Clone, Debug, PartialEq)]
+pub struct BooleanParamDefinition {
+ pub key: String,
+ pub label: String,
+ pub default_value: bool,
+ pub group: Option<String>,
+ pub keyframable: Option<bool>,
+ pub dependencies: Vec<ParamDependency>,
+}
+
+#[derive(Clone, Debug, PartialEq)]
+pub struct ColorParamDefinition {
+ pub key: String,
+ pub label: String,
+ pub default_value: String,
+ pub group: Option<String>,
+ pub keyframable: Option<bool>,
+ pub dependencies: Vec<ParamDependency>,
+}
+
+#[derive(Clone, Debug, PartialEq)]
+pub struct SelectParamDefinition {
+ pub key: String,
+ pub label: String,
+ pub default_value: String,
+ pub options: Vec<SelectOption>,
+ pub group: Option<String>,
+ pub keyframable: Option<bool>,
+ pub dependencies: Vec<ParamDependency>,
+}
+
+#[derive(Clone, Debug, PartialEq)]
+pub struct TextParamDefinition {
+ pub key: String,
+ pub label: String,
+ pub default_value: String,
+ pub group: Option<String>,
+ pub keyframable: Option<bool>,
+ pub dependencies: Vec<ParamDependency>,
+}
+
+#[derive(Clone, Debug, PartialEq)]
+pub struct FontParamDefinition {
+ pub key: String,
+ pub label: String,
+ pub default_value: String,
+ pub group: Option<String>,
+ pub keyframable: Option<bool>,
+ pub dependencies: Vec<ParamDependency>,
+}
+
+#[derive(Clone, Debug, PartialEq)]
+pub enum ParamDefinition {
+ Number(NumberParamDefinition),
+ Boolean(BooleanParamDefinition),
+ Color(ColorParamDefinition),
+ Select(SelectParamDefinition),
+ Text(TextParamDefinition),
+ Font(FontParamDefinition),
+}
+
+impl ParamDefinition {
+ pub fn key(&self) -> &str {
+ match self {
+ Self::Number(param) => &param.key,
+ Self::Boolean(param) => &param.key,
+ Self::Color(param) => &param.key,
+ Self::Select(param) => &param.key,
+ Self::Text(param) => &param.key,
+ Self::Font(param) => &param.key,
+ }
+ }
+
+ pub fn label(&self) -> &str {
+ match self {
+ Self::Number(param) => &param.label,
+ Self::Boolean(param) => &param.label,
+ Self::Color(param) => &param.label,
+ Self::Select(param) => &param.label,
+ Self::Text(param) => &param.label,
+ Self::Font(param) => &param.label,
+ }
+ }
+
+ pub fn default_value(&self) -> ParamValue {
+ match self {
+ Self::Number(param) => ParamValue::Number(param.default_value),
+ Self::Boolean(param) => ParamValue::Bool(param.default_value),
+ Self::Color(param) => ParamValue::String(param.default_value.clone()),
+ Self::Select(param) => ParamValue::String(param.default_value.clone()),
+ Self::Text(param) => ParamValue::String(param.default_value.clone()),
+ Self::Font(param) => ParamValue::String(param.default_value.clone()),
+ }
+ }
+
+ pub fn numeric_range(&self) -> Option<(f64, Option<f64>, f64)> {
+ match self {
+ Self::Number(param) => Some((param.min, param.max, param.step)),
+ _ => None,
+ }
+ }
+}
+
+pub fn build_default_param_values(params: &[ParamDefinition]) -> ParamValues {
+ params
+ .iter()
+ .map(|param| (param.key().to_string(), param.default_value()))
+ .collect()
+}
diff --git a/rust/crates/effect-defs/src/registry.rs b/rust/crates/effect-defs/src/registry.rs
new file mode 100644
index 00000000..340f9b9b
--- /dev/null
+++ b/rust/crates/effect-defs/src/registry.rs
@@ -0,0 +1,35 @@
+use std::collections::HashMap;
+
+use crate::{EffectDefsError, types::EffectDefinition};
+
+#[derive(Default)]
+pub struct EffectsRegistry {
+ definitions: HashMap<String, EffectDefinition>,
+}
+
+impl EffectsRegistry {
+ pub fn new() -> Self {
+ Self::default()
+ }
+
+ pub fn register(&mut self, definition: EffectDefinition) {
+ self.definitions
+ .insert(definition.effect_type.to_string(), definition);
+ }
+
+ pub fn has(&self, key: &str) -> bool {
+ self.definitions.contains_key(key)
+ }
+
+ pub fn get(&self, key: &str) -> Result<&EffectDefinition, EffectDefsError> {
+ self.definitions
+ .get(key)
+ .ok_or_else(|| EffectDefsError::UnknownEffect {
+ effect_type: key.to_string(),
+ })
+ }
+
+ pub fn get_all(&self) -> Vec<&EffectDefinition> {
+ self.definitions.values().collect()
+ }
+}
diff --git a/rust/crates/effect-defs/src/types.rs b/rust/crates/effect-defs/src/types.rs
new file mode 100644
index 00000000..c9815fd7
--- /dev/null
+++ b/rust/crates/effect-defs/src/types.rs
@@ -0,0 +1,30 @@
+use std::collections::HashMap;
+
+use effects::{EffectPass, UniformValue};
+use scene::params::ParamValues;
+
+use crate::params::ParamDefinition;
+
+pub struct PassContext<'a> {
+ pub effect_params: &'a ParamValues,
+ pub width: u32,
+ pub height: u32,
+}
+
+pub struct EffectPassTemplate {
+ pub shader: &'static str,
+ pub uniforms: fn(&PassContext) -> HashMap<String, UniformValue>,
+}
+
+pub struct EffectRendererConfig {
+ pub passes: Vec<EffectPassTemplate>,
+ pub build_passes: Option<fn(&PassContext) -> Vec<EffectPass>>,
+}
+
+pub struct EffectDefinition {
+ pub effect_type: &'static str,
+ pub name: &'static str,
+ pub keywords: &'static [&'static str],
+ pub params: Vec<ParamDefinition>,
+ pub renderer: EffectRendererConfig,
+}
diff --git a/rust/crates/gpu/src/context.rs b/rust/crates/gpu/src/context.rs
index da835280..04d7d148 100644
--- a/rust/crates/gpu/src/context.rs
+++ b/rust/crates/gpu/src/context.rs
@@ -317,6 +317,68 @@ impl GpuContext {
})
}
+ pub async fn read_texture(
+ &self,
+ texture: &wgpu::Texture,
+ ) -> Result<Vec<u8>, GpuError> {
+ let size = texture.size();
+ let unpadded_bytes_per_row = size.width * 4;
+ let align = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT;
+ let padded_bytes_per_row = unpadded_bytes_per_row.div_ceil(align) * align;
+ let buffer = self.device.create_buffer(&wgpu::BufferDescriptor {
+ label: Some("gpu-readback-buffer"),
+ size: (padded_bytes_per_row * size.height) as u64,
+ usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
+ mapped_at_creation: false,
+ });
+ let mut encoder = self
+ .device
+ .create_command_encoder(&wgpu::CommandEncoderDescriptor {
+ label: Some("gpu-readback-encoder"),
+ });
+ encoder.copy_texture_to_buffer(
+ texture.as_image_copy(),
+ wgpu::TexelCopyBufferInfo {
+ buffer: &buffer,
+ layout: wgpu::TexelCopyBufferLayout {
+ offset: 0,
+ bytes_per_row: Some(padded_bytes_per_row),
+ rows_per_image: Some(size.height),
+ },
+ },
+ size,
+ );
+ self.queue.submit([encoder.finish()]);
+
+ let slice = buffer.slice(..);
+ let (sender, receiver) = std::sync::mpsc::channel();
+ slice.map_async(wgpu::MapMode::Read, move |result| {
+ let _ = sender.send(result);
+ });
+ self.device
+ .poll(wgpu::PollType::wait_indefinitely())
+ .map_err(|error| GpuError::Readback(error.to_string()))?;
+ receiver
+ .recv()
+ .map_err(|error| GpuError::Readback(error.to_string()))?
+ .map_err(|error| GpuError::Readback(error.to_string()))?;
+
+ let padded = slice.get_mapped_range();
+ let mut pixels =
+ vec![0u8; (unpadded_bytes_per_row * size.height) as usize];
+ for row in 0..size.height as usize {
+ let padded_start = row * padded_bytes_per_row as usize;
+ let unpadded_start = row * unpadded_bytes_per_row as usize;
+ pixels[unpadded_start..unpadded_start + unpadded_bytes_per_row as usize]
+ .copy_from_slice(
+ &padded[padded_start..padded_start + unpadded_bytes_per_row as usize],
+ );
+ }
+ drop(padded);
+ buffer.unmap();
+ Ok(pixels)
+ }
+
pub fn instance(&self) -> &wgpu::Instance {
&self.instance
}
diff --git a/rust/crates/gpu/src/lib.rs b/rust/crates/gpu/src/lib.rs
index b6ca6c1c..0c647459 100644
--- a/rust/crates/gpu/src/lib.rs
+++ b/rust/crates/gpu/src/lib.rs
@@ -18,4 +18,6 @@ pub enum GpuError {
CreateSurface(#[from] wgpu::CreateSurfaceError),
#[error("The output surface does not support the required texture format")]
UnsupportedSurfaceFormat,
+ #[error("Failed to read back a texture: {0}")]
+ Readback(String),
}
diff --git a/rust/crates/graphics/Cargo.toml b/rust/crates/graphics/Cargo.toml
new file mode 100644
index 00000000..93a5c26b
--- /dev/null
+++ b/rust/crates/graphics/Cargo.toml
@@ -0,0 +1,7 @@
+[package]
+name = "graphics"
+version = "0.1.0"
+edition = "2024"
+
+[dependencies]
+scene = { version = "0.1.0", path = "../scene" }
diff --git a/rust/crates/graphics/src/lib.rs b/rust/crates/graphics/src/lib.rs
new file mode 100644
index 00000000..8b137891
--- /dev/null
+++ b/rust/crates/graphics/src/lib.rs
@@ -0,0 +1 @@
+
diff --git a/rust/crates/mask-defs/Cargo.toml b/rust/crates/mask-defs/Cargo.toml
new file mode 100644
index 00000000..7306156d
--- /dev/null
+++ b/rust/crates/mask-defs/Cargo.toml
@@ -0,0 +1,8 @@
+[package]
+name = "mask-defs"
+version = "0.1.0"
+edition = "2024"
+
+[dependencies]
+scene = { version = "0.1.0", path = "../scene" }
+thiserror = "2"
diff --git a/rust/crates/mask-defs/src/lib.rs b/rust/crates/mask-defs/src/lib.rs
new file mode 100644
index 00000000..8b137891
--- /dev/null
+++ b/rust/crates/mask-defs/src/lib.rs
@@ -0,0 +1 @@
+
diff --git a/rust/crates/media/Cargo.toml b/rust/crates/media/Cargo.toml
new file mode 100644
index 00000000..2272ce17
--- /dev/null
+++ b/rust/crates/media/Cargo.toml
@@ -0,0 +1,9 @@
+[package]
+name = "media"
+version = "0.1.0"
+edition = "2024"
+
+[dependencies]
+ffmpeg-next = "8"
+thiserror = "2"
+time = { version = "0.1.0", path = "../time" }
diff --git a/rust/crates/media/src/lib.rs b/rust/crates/media/src/lib.rs
new file mode 100644
index 00000000..239f5c75
--- /dev/null
+++ b/rust/crates/media/src/lib.rs
@@ -0,0 +1,610 @@
+use std::collections::HashMap;
+use std::path::Path;
+
+use ffmpeg_next as ffmpeg;
+use ffmpeg::codec::context::Context as CodecContext;
+use ffmpeg::format::context::Input;
+use ffmpeg::format::{Pixel, Sample, sample};
+use ffmpeg::software::{resampling, scaling};
+use ffmpeg::{ChannelLayout, Rational, media};
+pub use time::MediaTime;
+
+const MICROS_PER_SECOND: i64 = 1_000_000;
+const AUDIO_PREROLL_SECONDS: f64 = 0.5;
+const FORWARD_DECODE_LIMIT_SECONDS: f64 = 10.0;
+const EOF_RESEEK_MARGIN_SECONDS: f64 = 2.0;
+
+#[derive(Debug, thiserror::Error)]
+pub enum Error {
+ #[error("ffmpeg error: {0}")]
+ Ffmpeg(#[from] ffmpeg::Error),
+ #[error("no video stream found")]
+ NoVideoStream,
+ #[error("no audio stream found")]
+ NoAudioStream,
+ #[error("stream index {0} does not exist")]
+ StreamNotFound(usize),
+ #[error("stream index {0} is not a video stream")]
+ NotAVideoStream(usize),
+ #[error("stream index {0} is not an audio stream")]
+ NotAnAudioStream(usize),
+ #[error("no frames could be decoded from stream {0}")]
+ NoFramesDecoded(usize),
+ #[error("invalid time range: start must be before end")]
+ InvalidTimeRange,
+ #[error("bucket count must be at least 1")]
+ InvalidBucketCount,
+ #[error("max dimension must be at least 1")]
+ InvalidMaxDimension,
+}
+
+pub type Result<T, E = Error> = std::result::Result<T, E>;
+
+#[derive(Debug, Clone)]
+pub struct VideoStreamInfo {
+ pub index: usize,
+ pub width: u32,
+ pub height: u32,
+ pub frame_rate: f64,
+ pub frame_count: Option<u64>,
+}
+
+#[derive(Debug, Clone)]
+pub struct AudioStreamInfo {
+ pub index: usize,
+ pub channels: u32,
+ pub sample_rate: u32,
+}
+
+#[derive(Debug, Clone)]
+pub struct MediaInfo {
+ pub duration: MediaTime,
+ pub video_streams: Vec<VideoStreamInfo>,
+ pub audio_streams: Vec<AudioStreamInfo>,
+}
+
+#[derive(Debug, Clone, PartialEq)]
+pub struct VideoFrame {
+ pub width: u32,
+ pub height: u32,
+ pub data: Vec<u8>,
+}
+
+#[derive(Debug, Clone)]
+pub struct AudioSamples {
+ pub sample_rate: u32,
+ pub channels: u32,
+ pub samples: Vec<f32>,
+}
+
+fn seconds_to_ts(seconds: f64, time_base: Rational) -> i64 {
+ (seconds * f64::from(time_base.denominator()) / f64::from(time_base.numerator())).round()
+ as i64
+}
+
+fn ts_to_seconds(ts: i64, time_base: Rational) -> f64 {
+ ts as f64 * f64::from(time_base.numerator()) / f64::from(time_base.denominator())
+}
+
+fn seconds_to_micros(seconds: f64) -> i64 {
+ (seconds * MICROS_PER_SECOND as f64) as i64
+}
+
+fn effective_layout(layout: ChannelLayout, channels: u16) -> ChannelLayout {
+ if layout.is_empty() {
+ ChannelLayout::default(i32::from(channels))
+ } else {
+ layout
+ }
+}
+
+struct VideoPipeline {
+ stream_index: usize,
+ decoder: ffmpeg::codec::decoder::Video,
+ time_base: Rational,
+ last: Option<(i64, ffmpeg::frame::Video)>,
+ drained: bool,
+ scaler: Option<(Pixel, u32, u32, scaling::Context)>,
+}
+
+impl VideoPipeline {
+ fn open(input: &Input, stream_index: usize) -> Result<Self> {
+ let stream = input
+ .streams()
+ .find(|stream| stream.index() == stream_index)
+ .ok_or(Error::StreamNotFound(stream_index))?;
+ if stream.parameters().medium() != media::Type::Video {
+ return Err(Error::NotAVideoStream(stream_index));
+ }
+ let decoder = CodecContext::from_parameters(stream.parameters())?
+ .decoder()
+ .video()?;
+ Ok(Self {
+ stream_index,
+ decoder,
+ time_base: stream.time_base(),
+ last: None,
+ drained: false,
+ scaler: None,
+ })
+ }
+
+ fn frame_at(
+ &mut self,
+ input: &mut Input,
+ target_ts: i64,
+ seek_seconds: f64,
+ ) -> Result<ffmpeg::frame::Video> {
+ if let Some((last_pts, last_frame)) = &self.last {
+ if target_ts >= *last_pts {
+ if self.drained {
+ return Ok(last_frame.clone());
+ }
+ let forward_limit =
+ seconds_to_ts(FORWARD_DECODE_LIMIT_SECONDS, self.time_base);
+ if target_ts - last_pts <= forward_limit {
+ let candidate = self.last.take();
+ return self.decode_forward(input, target_ts, candidate);
+ }
+ }
+ }
+ self.decoder.flush();
+ self.drained = false;
+ self.last = None;
+ let micros = seconds_to_micros(seek_seconds.max(0.0));
+ input.seek(micros, ..micros)?;
+ match self.decode_forward(input, target_ts, None) {
+ Err(Error::NoFramesDecoded(_)) => {
+ self.decoder.flush();
+ input.seek(0, ..0)?;
+ self.decode_forward(input, i64::MAX, None)
+ }
+ result => result,
+ }
+ }
+
+ fn decode_forward(
+ &mut self,
+ input: &mut Input,
+ target_ts: i64,
+ mut candidate: Option<(i64, ffmpeg::frame::Video)>,
+ ) -> Result<ffmpeg::frame::Video> {
+ let mut frame = ffmpeg::frame::Video::empty();
+ for (stream, packet) in input.packets() {
+ if stream.index() != self.stream_index {
+ continue;
+ }
+ self.decoder.send_packet(&packet)?;
+ while self.decoder.receive_frame(&mut frame).is_ok() {
+ let pts = frame.pts().unwrap_or(i64::MIN);
+ if pts <= target_ts {
+ candidate = Some((pts, frame.clone()));
+ } else {
+ self.last = Some((pts, frame.clone()));
+ return match candidate {
+ Some((_, frame)) => Ok(frame),
+ None => Ok(frame.clone()),
+ };
+ }
+ frame = ffmpeg::frame::Video::empty();
+ }
+ }
+ self.decoder.send_eof()?;
+ while self.decoder.receive_frame(&mut frame).is_ok() {
+ let pts = frame.pts().unwrap_or(i64::MIN);
+ if pts <= target_ts {
+ candidate = Some((pts, frame.clone()));
+ } else {
+ self.last = Some((pts, frame.clone()));
+ return match candidate {
+ Some((_, frame)) => Ok(frame),
+ None => Ok(frame.clone()),
+ };
+ }
+ frame = ffmpeg::frame::Video::empty();
+ }
+ self.drained = true;
+ match candidate {
+ Some((pts, frame)) => {
+ self.last = Some((pts, frame.clone()));
+ Ok(frame)
+ }
+ None => Err(Error::NoFramesDecoded(self.stream_index)),
+ }
+ }
+
+ fn rgba_frame(&mut self, frame: &ffmpeg::frame::Video) -> Result<VideoFrame> {
+ convert_to_rgba(
+ &mut self.scaler,
+ frame,
+ frame.width(),
+ frame.height(),
+ )
+ }
+}
+
+fn convert_to_rgba(
+ cache: &mut Option<(Pixel, u32, u32, scaling::Context)>,
+ frame: &ffmpeg::frame::Video,
+ width: u32,
+ height: u32,
+) -> Result<VideoFrame> {
+ let key = (frame.format(), frame.width(), frame.height());
+ let matches = cache
+ .as_ref()
+ .is_some_and(|(format, w, h, _)| (*format, *w, *h) == key);
+ if !matches {
+ let scaler = scaling::Context::get(
+ frame.format(),
+ frame.width(),
+ frame.height(),
+ Pixel::RGBA,
+ width,
+ height,
+ scaling::Flags::BILINEAR,
+ )?;
+ *cache = Some((key.0, key.1, key.2, scaler));
+ }
+ let scaler = &mut cache.as_mut().expect("scaler just inserted").3;
+ let mut converted = ffmpeg::frame::Video::new(Pixel::RGBA, width, height);
+ scaler.run(frame, &mut converted)?;
+ let stride = converted.stride(0);
+ let row_bytes = width as usize * 4;
+ let plane = converted.data(0);
+ let mut data = Vec::with_capacity(row_bytes * height as usize);
+ if stride == row_bytes {
+ data.extend_from_slice(&plane[..row_bytes * height as usize]);
+ } else {
+ for row in 0..height as usize {
+ data.extend_from_slice(&plane[row * stride..row * stride + row_bytes]);
+ }
+ }
+ Ok(VideoFrame { width, height, data })
+}
+
+pub struct Decoder {
+ input: Input,
+ video: HashMap<usize, VideoPipeline>,
+}
+
+impl Decoder {
+ pub fn open(path: impl AsRef<Path>) -> Result<Self> {
+ let input = ffmpeg::format::input(path.as_ref())?;
+ Ok(Self {
+ input,
+ video: HashMap::new(),
+ })
+ }
+
+ pub fn info(&self) -> MediaInfo {
+ let mut video_streams = Vec::new();
+ let mut audio_streams = Vec::new();
+ let mut stream_duration_seconds = 0.0_f64;
+ for stream in self.input.streams() {
+ let duration = stream.duration();
+ if duration > 0 {
+ stream_duration_seconds =
+ stream_duration_seconds.max(ts_to_seconds(duration, stream.time_base()));
+ }
+ match stream.parameters().medium() {
+ media::Type::Video => {
+ let Ok(context) = CodecContext::from_parameters(stream.parameters()) else {
+ continue;
+ };
+ let Ok(decoder) = context.decoder().video() else {
+ continue;
+ };
+ let rate = stream.avg_frame_rate();
+ let frame_rate = if rate.denominator() == 0 {
+ 0.0
+ } else {
+ f64::from(rate.numerator()) / f64::from(rate.denominator())
+ };
+ let frames = stream.frames();
+ video_streams.push(VideoStreamInfo {
+ index: stream.index(),
+ width: decoder.width(),
+ height: decoder.height(),
+ frame_rate,
+ frame_count: (frames > 0).then_some(frames as u64),
+ });
+ }
+ media::Type::Audio => {
+ let Ok(context) = CodecContext::from_parameters(stream.parameters()) else {
+ continue;
+ };
+ let Ok(decoder) = context.decoder().audio() else {
+ continue;
+ };
+ audio_streams.push(AudioStreamInfo {
+ index: stream.index(),
+ channels: u32::from(decoder.channels()),
+ sample_rate: decoder.rate(),
+ });
+ }
+ _ => {}
+ }
+ }
+ let duration_micros = self.input.duration();
+ let duration_seconds = if duration_micros > 0 {
+ duration_micros as f64 / MICROS_PER_SECOND as f64
+ } else {
+ stream_duration_seconds
+ };
+ MediaInfo {
+ duration: MediaTime::from_seconds_f64(duration_seconds).unwrap_or(MediaTime::ZERO),
+ video_streams,
+ audio_streams,
+ }
+ }
+
+ pub fn decode_video_frame(&mut self, stream_index: usize, time: MediaTime) -> Result<VideoFrame> {
+ let raw = self.decode_video_raw(stream_index, time)?;
+ let pipeline = self
+ .video
+ .get_mut(&stream_index)
+ .expect("pipeline created by decode_video_raw");
+ pipeline.rgba_frame(&raw)
+ }
+
+ fn decode_video_raw(
+ &mut self,
+ stream_index: usize,
+ time: MediaTime,
+ ) -> Result<ffmpeg::frame::Video> {
+ if !self.video.contains_key(&stream_index) {
+ let pipeline = VideoPipeline::open(&self.input, stream_index)?;
+ self.video.insert(stream_index, pipeline);
+ }
+ let target_seconds = time.to_seconds_f64().max(0.0);
+ let duration_seconds = self.input.duration().max(0) as f64 / MICROS_PER_SECOND as f64;
+ let pipeline = self
+ .video
+ .get_mut(&stream_index)
+ .expect("pipeline just inserted");
+ let mut target_ts = seconds_to_ts(target_seconds, pipeline.time_base);
+ let mut seek_seconds = target_seconds;
+ if duration_seconds > 0.0 && target_seconds > duration_seconds {
+ target_ts = i64::MAX;
+ seek_seconds = (duration_seconds - EOF_RESEEK_MARGIN_SECONDS).max(0.0);
+ }
+ pipeline.frame_at(&mut self.input, target_ts, seek_seconds)
+ }
+
+ pub fn decode_audio_range(
+ &mut self,
+ stream_index: usize,
+ start: MediaTime,
+ end: MediaTime,
+ ) -> Result<AudioSamples> {
+ self.decode_audio_range_inner(stream_index, start, end, true)
+ }
+
+ fn decode_audio_range_inner(
+ &mut self,
+ stream_index: usize,
+ start: MediaTime,
+ end: MediaTime,
+ pad_to_range: bool,
+ ) -> Result<AudioSamples> {
+ if end <= start {
+ return Err(Error::InvalidTimeRange);
+ }
+ let stream = self
+ .input
+ .streams()
+ .find(|stream| stream.index() == stream_index)
+ .ok_or(Error::StreamNotFound(stream_index))?;
+ if stream.parameters().medium() != media::Type::Audio {
+ return Err(Error::NotAnAudioStream(stream_index));
+ }
+ let time_base = stream.time_base();
+ let mut decoder = CodecContext::from_parameters(stream.parameters())?
+ .decoder()
+ .audio()?;
+ let rate = decoder.rate();
+ let channels = u32::from(decoder.channels());
+ let layout = effective_layout(decoder.channel_layout(), decoder.channels());
+ let mut resampler = resampling::Context::get(
+ decoder.format(),
+ layout,
+ rate,
+ Sample::F32(sample::Type::Packed),
+ layout,
+ rate,
+ )?;
+ let start_seconds = start.to_seconds_f64().max(0.0);
+ let end_seconds = end.to_seconds_f64();
+ let seek_seconds = (start_seconds - AUDIO_PREROLL_SECONDS).max(0.0);
+ let seek_micros = seconds_to_micros(seek_seconds);
+ self.input.seek(seek_micros, ..seek_micros)?;
+
+ let rate_f64 = f64::from(rate);
+ let channel_count = channels as usize;
+ let mut anchor_seconds: Option<f64> = None;
+ let mut produced: i64 = 0;
+ let mut out_samples: Vec<f32> = Vec::new();
+ let mut done = false;
+
+ let mut frame = ffmpeg::frame::Audio::empty();
+ for (packet_stream, packet) in self.input.packets() {
+ if packet_stream.index() != stream_index {
+ continue;
+ }
+ decoder.send_packet(&packet)?;
+ while decoder.receive_frame(&mut frame).is_ok() {
+ if frame.channel_layout().is_empty() {
+ frame.set_channel_layout(layout);
+ }
+ if anchor_seconds.is_none() {
+ anchor_seconds = Some(match frame.pts() {
+ Some(pts) => ts_to_seconds(pts, time_base),
+ None => seek_seconds,
+ });
+ }
+ let mut converted = ffmpeg::frame::Audio::empty();
+ resampler.run(&frame, &mut converted)?;
+ append_resampled(
+ &converted,
+ anchor_seconds.expect("anchor set above"),
+ start_seconds,
+ end_seconds,
+ rate_f64,
+ channel_count,
+ &mut produced,
+ &mut out_samples,
+ );
+ frame = ffmpeg::frame::Audio::empty();
+ let anchor = anchor_seconds.expect("anchor set above");
+ let end_index = ((end_seconds - anchor) * rate_f64).round() as i64;
+ if produced >= end_index {
+ done = true;
+ break;
+ }
+ }
+ if done {
+ break;
+ }
+ }
+ if !done {
+ decoder.send_eof()?;
+ while decoder.receive_frame(&mut frame).is_ok() {
+ if frame.channel_layout().is_empty() {
+ frame.set_channel_layout(layout);
+ }
+ if anchor_seconds.is_none() {
+ anchor_seconds = Some(match frame.pts() {
+ Some(pts) => ts_to_seconds(pts, time_base),
+ None => seek_seconds,
+ });
+ }
+ let mut converted = ffmpeg::frame::Audio::empty();
+ resampler.run(&frame, &mut converted)?;
+ append_resampled(
+ &converted,
+ anchor_seconds.expect("anchor set above"),
+ start_seconds,
+ end_seconds,
+ rate_f64,
+ channel_count,
+ &mut produced,
+ &mut out_samples,
+ );
+ frame = ffmpeg::frame::Audio::empty();
+ }
+ let mut converted = ffmpeg::frame::Audio::empty();
+ if resampler.flush(&mut converted).is_ok() && converted.samples() > 0 {
+ append_resampled(
+ &converted,
+ anchor_seconds.unwrap_or(seek_seconds),
+ start_seconds,
+ end_seconds,
+ rate_f64,
+ channel_count,
+ &mut produced,
+ &mut out_samples,
+ );
+ }
+ }
+ if pad_to_range && let Some(anchor) = anchor_seconds {
+ let start_index = (((start_seconds - anchor) * rate_f64).round() as i64).max(0);
+ let end_index = (((end_seconds - anchor) * rate_f64).round() as i64).max(start_index);
+ let expected = ((end_index - start_index) as usize) * channel_count;
+ if out_samples.len() < expected {
+ out_samples.resize(expected, 0.0);
+ }
+ }
+ Ok(AudioSamples {
+ sample_rate: rate,
+ channels,
+ samples: out_samples,
+ })
+ }
+}
+
+fn append_resampled(
+ converted: &ffmpeg::frame::Audio,
+ anchor_seconds: f64,
+ start_seconds: f64,
+ end_seconds: f64,
+ rate: f64,
+ channels: usize,
+ produced: &mut i64,
+ out_samples: &mut Vec<f32>,
+) {
+ let count = converted.samples() as i64;
+ if count == 0 {
+ return;
+ }
+ let start_index = (((start_seconds - anchor_seconds) * rate).round() as i64).max(0);
+ let end_index = (((end_seconds - anchor_seconds) * rate).round() as i64).max(start_index);
+ let low = (*produced).max(start_index);
+ let high = (*produced + count).min(end_index);
+ if low < high {
+ if out_samples.is_empty() && low > start_index {
+ out_samples.resize(((low - start_index) as usize) * channels, 0.0);
+ }
+ let data = converted.data(0);
+ let first = (low - *produced) as usize * channels;
+ let last = (high - *produced) as usize * channels;
+ for bytes in data[first * 4..last * 4].chunks_exact(4) {
+ out_samples.push(f32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]));
+ }
+ }
+ *produced += count;
+}
+
+pub fn thumbnail(path: impl AsRef<Path>, max_dimension: u32) -> Result<VideoFrame> {
+ if max_dimension == 0 {
+ return Err(Error::InvalidMaxDimension);
+ }
+ let mut decoder = Decoder::open(path.as_ref())?;
+ let video_stream = decoder
+ .info()
+ .video_streams
+ .first()
+ .cloned()
+ .ok_or(Error::NoVideoStream)?;
+ let raw = decoder.decode_video_raw(video_stream.index, MediaTime::ZERO)?;
+ let source_max = raw.width().max(raw.height());
+ let scale = (f64::from(max_dimension) / f64::from(source_max)).min(1.0);
+ let width = ((f64::from(raw.width()) * scale).round() as u32).max(1);
+ let height = ((f64::from(raw.height()) * scale).round() as u32).max(1);
+ let mut cache = None;
+ convert_to_rgba(&mut cache, &raw, width, height)
+}
+
+pub fn waveform_summary(path: impl AsRef<Path>, bucket_count: usize) -> Result<Vec<(f32, f32)>> {
+ if bucket_count == 0 {
+ return Err(Error::InvalidBucketCount);
+ }
+ let mut decoder = Decoder::open(path.as_ref())?;
+ let info = decoder.info();
+ let audio_stream = info.audio_streams.first().ok_or(Error::NoAudioStream)?;
+ let samples = decoder.decode_audio_range_inner(
+ audio_stream.index,
+ MediaTime::ZERO,
+ info.duration,
+ false,
+ )?;
+ let channels = samples.channels as usize;
+ let frame_count = samples.samples.len() / channels;
+ let bucket_size = frame_count.div_ceil(bucket_count).max(1);
+ Ok((0..bucket_count)
+ .map(|bucket| {
+ let start_frame = bucket * bucket_size;
+ let end_frame = (start_frame + bucket_size).min(frame_count);
+ if start_frame >= end_frame {
+ return (0.0, 0.0);
+ }
+ let slice = &samples.samples[start_frame * channels..end_frame * channels];
+ let mut low = f32::INFINITY;
+ let mut high = f32::NEG_INFINITY;
+ for &sample in slice {
+ low = low.min(sample);
+ high = high.max(sample);
+ }
+ (low, high)
+ })
+ .collect())
+}
diff --git a/rust/crates/media/tests/decode.rs b/rust/crates/media/tests/decode.rs
new file mode 100644
index 00000000..7fcd6cd5
--- /dev/null
+++ b/rust/crates/media/tests/decode.rs
@@ -0,0 +1,193 @@
+use media::{Decoder, Error, MediaTime, thumbnail, waveform_summary};
+
+const SAMPLE_MP4: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/tests/fixtures/sample.mp4");
+const TONE_WAV: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/tests/fixtures/tone.wav");
+
+fn seconds(value: f64) -> MediaTime {
+ MediaTime::from_seconds_f64(value).expect("finite seconds")
+}
+
+#[test]
+fn reports_media_info() {
+ let decoder = Decoder::open(SAMPLE_MP4).expect("open sample.mp4");
+ let info = decoder.info();
+ let duration_seconds = info.duration.to_seconds_f64();
+ assert!(
+ (duration_seconds - 2.0).abs() < 0.05,
+ "duration was {duration_seconds}"
+ );
+ let video = info.video_streams.first().expect("video stream");
+ assert_eq!(video.width, 320);
+ assert_eq!(video.height, 180);
+ assert!(
+ (video.frame_rate - 30.0).abs() < 0.01,
+ "frame rate was {}",
+ video.frame_rate
+ );
+ assert_eq!(video.frame_count, Some(60));
+ let audio = info.audio_streams.first().expect("audio stream");
+ assert_eq!(audio.sample_rate, 44100);
+ assert!(audio.channels >= 1);
+}
+
+#[test]
+fn decodes_video_frame_at_zero_and_one_second() {
+ let mut decoder = Decoder::open(SAMPLE_MP4).expect("open sample.mp4");
+ let first = decoder
+ .decode_video_frame(0, seconds(0.0))
+ .expect("frame at t=0");
+ assert_eq!(first.width, 320);
+ assert_eq!(first.height, 180);
+ assert_eq!(first.data.len(), 320 * 180 * 4);
+ let first_pixel = &first.data[0..4];
+ assert!(
+ first.data.chunks_exact(4).any(|pixel| pixel != first_pixel),
+ "frame at t=0 is uniform"
+ );
+ let later = decoder
+ .decode_video_frame(0, seconds(1.0))
+ .expect("frame at t=1");
+ assert_eq!(later.width, 320);
+ assert_eq!(later.height, 180);
+ assert!(
+ later.data.chunks_exact(4).any(|pixel| pixel != first_pixel),
+ "frame at t=1 is uniform"
+ );
+ assert_ne!(first.data, later.data, "frames at t=0 and t=1 are identical");
+}
+
+#[test]
+fn decodes_sequential_frames_forward() {
+ let mut decoder = Decoder::open(SAMPLE_MP4).expect("open sample.mp4");
+ let mut previous = decoder.decode_video_frame(0, seconds(0.25)).expect("t=0.25");
+ for step in 1..8 {
+ let time = 0.25 + f64::from(step) * 0.2;
+ let frame = decoder.decode_video_frame(0, seconds(time)).expect("frame");
+ assert_eq!((frame.width, frame.height), (320, 180));
+ previous = frame;
+ }
+ let _ = previous;
+}
+
+#[test]
+fn time_past_eof_returns_last_frame() {
+ let mut decoder = Decoder::open(SAMPLE_MP4).expect("open sample.mp4");
+ let frame = decoder
+ .decode_video_frame(0, seconds(10.0))
+ .expect("frame past EOF");
+ assert_eq!(frame.width, 320);
+ assert_eq!(frame.height, 180);
+ assert_eq!(frame.data.len(), 320 * 180 * 4);
+}
+
+#[test]
+fn rejects_wrong_stream_type() {
+ let mut decoder = Decoder::open(SAMPLE_MP4).expect("open sample.mp4");
+ let info = decoder.info();
+ let audio_index = info.audio_streams.first().expect("audio stream").index;
+ let result = decoder.decode_video_frame(audio_index, seconds(0.0));
+ assert!(matches!(result, Err(Error::NotAVideoStream(_))));
+ let video_index = info.video_streams.first().expect("video stream").index;
+ let result = decoder.decode_audio_range(video_index, seconds(0.0), seconds(1.0));
+ assert!(matches!(result, Err(Error::NotAnAudioStream(_))));
+ let result = decoder.decode_video_frame(99, seconds(0.0));
+ assert!(matches!(result, Err(Error::StreamNotFound(99))));
+}
+
+#[test]
+fn decodes_tone_waveform() {
+ let mut decoder = Decoder::open(TONE_WAV).expect("open tone.wav");
+ let info = decoder.info();
+ let audio = info.audio_streams.first().expect("audio stream");
+ assert_eq!(audio.sample_rate, 44100);
+ let decoded = decoder
+ .decode_audio_range(audio.index, seconds(0.0), seconds(1.0))
+ .expect("decode range");
+ assert_eq!(decoded.sample_rate, 44100);
+ let channels = decoded.channels as usize;
+ let frame_count = decoded.samples.len() / channels;
+ assert!(
+ (frame_count as i64 - 44100).abs() <= 2,
+ "frame count was {frame_count}"
+ );
+ let mean_square: f32 =
+ decoded.samples.iter().map(|sample| sample * sample).sum::<f32>()
+ / decoded.samples.len() as f32;
+ assert!(mean_square.sqrt() > 0.05, "rms was {}", mean_square.sqrt());
+ assert!(
+ decoded.samples.iter().all(|sample| sample.abs() <= 1.0),
+ "samples exceed [-1, 1]"
+ );
+ let mut crossings = 0usize;
+ let mut previous_sign = decoded.samples[0].is_sign_negative();
+ for &sample in &decoded.samples[1..] {
+ let sign = sample.is_sign_negative();
+ if sign != previous_sign {
+ crossings += 1;
+ previous_sign = sign;
+ }
+ }
+ assert!(
+ (crossings as i64 - 440).abs() <= 22,
+ "zero crossings were {crossings}"
+ );
+}
+
+#[test]
+fn decodes_aac_audio_range() {
+ let mut decoder = Decoder::open(SAMPLE_MP4).expect("open sample.mp4");
+ let info = decoder.info();
+ let audio = info.audio_streams.first().expect("audio stream");
+ let decoded = decoder
+ .decode_audio_range(audio.index, seconds(0.5), seconds(1.5))
+ .expect("decode aac range");
+ assert_eq!(decoded.sample_rate, 44100);
+ let channels = decoded.channels as usize;
+ let frame_count = decoded.samples.len() / channels;
+ assert!(
+ (frame_count as i64 - 44100).abs() <= 100,
+ "frame count was {frame_count}"
+ );
+ let mean_square: f32 =
+ decoded.samples.iter().map(|sample| sample * sample).sum::<f32>()
+ / decoded.samples.len() as f32;
+ assert!(mean_square.sqrt() > 0.01, "rms was {}", mean_square.sqrt());
+}
+
+#[test]
+fn rejects_invalid_audio_range() {
+ let mut decoder = Decoder::open(TONE_WAV).expect("open tone.wav");
+ let result = decoder.decode_audio_range(0, seconds(1.0), seconds(0.5));
+ assert!(matches!(result, Err(Error::InvalidTimeRange)));
+}
+
+#[test]
+fn scales_thumbnail_to_max_dimension() {
+ let thumb = thumbnail(SAMPLE_MP4, 64).expect("thumbnail");
+ assert_eq!(thumb.width, 64);
+ assert_eq!(thumb.height, 36);
+ assert_eq!(thumb.data.len(), 64 * 36 * 4);
+ let first_pixel = &thumb.data[0..4];
+ assert!(
+ thumb.data.chunks_exact(4).any(|pixel| pixel != first_pixel),
+ "thumbnail is uniform"
+ );
+}
+
+#[test]
+fn summarizes_waveform_buckets() {
+ let buckets = waveform_summary(TONE_WAV, 10).expect("waveform summary");
+ assert_eq!(buckets.len(), 10);
+ for (low, high) in &buckets {
+ assert!(low <= high, "bucket min {low} exceeds max {high}");
+ assert!(*low >= -1.0 && *high <= 1.0, "bucket ({low}, {high}) out of range");
+ }
+ assert!(
+ buckets.iter().any(|(low, high)| *high > 0.1 && *low < -0.1),
+ "no bucket carries the 220Hz tone"
+ );
+ let single = waveform_summary(TONE_WAV, 1).expect("single bucket");
+ assert_eq!(single.len(), 1);
+ assert!(single[0].1 > 0.1);
+ assert!(matches!(waveform_summary(TONE_WAV, 0), Err(Error::InvalidBucketCount)));
+}
diff --git a/rust/crates/media/tests/fixtures/sample.mp4 b/rust/crates/media/tests/fixtures/sample.mp4
new file mode 100644
index 00000000..13c113e5
--- /dev/null
+++ b/rust/crates/media/tests/fixtures/sample.mp4
Binary files differ
diff --git a/rust/crates/media/tests/fixtures/tone.wav b/rust/crates/media/tests/fixtures/tone.wav
new file mode 100644
index 00000000..8d5f7886
--- /dev/null
+++ b/rust/crates/media/tests/fixtures/tone.wav
Binary files differ
diff --git a/rust/crates/playback/Cargo.toml b/rust/crates/playback/Cargo.toml
new file mode 100644
index 00000000..f1edb8f7
--- /dev/null
+++ b/rust/crates/playback/Cargo.toml
@@ -0,0 +1,7 @@
+[package]
+name = "playback"
+version = "0.1.0"
+edition = "2024"
+
+[dependencies]
+time = { version = "0.1.0", path = "../time" }
diff --git a/rust/crates/playback/src/lib.rs b/rust/crates/playback/src/lib.rs
new file mode 100644
index 00000000..2e10e818
--- /dev/null
+++ b/rust/crates/playback/src/lib.rs
@@ -0,0 +1,316 @@
+use time::{FrameRate, MediaTime};
+
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+pub enum PlaybackEvent {
+ Started,
+ Paused,
+ Seeked(MediaTime),
+ Updated(MediaTime),
+ ReachedEnd(MediaTime),
+}
+
+pub struct PlaybackClock {
+ is_playing: bool,
+ current_time: MediaTime,
+ total_duration: MediaTime,
+ frame_rate: Option<FrameRate>,
+ anchor_media: MediaTime,
+ anchor_wall_seconds: f64,
+ volume: f64,
+ previous_volume: f64,
+ muted: bool,
+ is_scrubbing: bool,
+}
+
+impl Default for PlaybackClock {
+ fn default() -> Self {
+ Self::new()
+ }
+}
+
+impl PlaybackClock {
+ pub fn new() -> Self {
+ Self {
+ is_playing: false,
+ current_time: MediaTime::ZERO,
+ total_duration: MediaTime::ZERO,
+ frame_rate: None,
+ anchor_media: MediaTime::ZERO,
+ anchor_wall_seconds: 0.0,
+ volume: 1.0,
+ previous_volume: 1.0,
+ muted: false,
+ is_scrubbing: false,
+ }
+ }
+
+ pub fn is_playing(&self) -> bool {
+ self.is_playing
+ }
+
+ pub fn current_time(&self) -> MediaTime {
+ self.current_time
+ }
+
+ pub fn total_duration(&self) -> MediaTime {
+ self.total_duration
+ }
+
+ pub fn volume(&self) -> f64 {
+ self.volume
+ }
+
+ pub fn is_muted(&self) -> bool {
+ self.muted
+ }
+
+ pub fn is_scrubbing(&self) -> bool {
+ self.is_scrubbing
+ }
+
+ pub fn set_total_duration(&mut self, duration: MediaTime) -> Vec<PlaybackEvent> {
+ self.total_duration = duration;
+ let clamped = self.clamp(self.current_time);
+ let reached_end = self.is_playing && clamped >= self.total_duration;
+ if clamped == self.current_time && !reached_end {
+ return Vec::new();
+ }
+ self.current_time = clamped;
+ let mut events = vec![PlaybackEvent::Seeked(clamped)];
+ if reached_end {
+ self.is_playing = false;
+ events.push(PlaybackEvent::Paused);
+ }
+ events
+ }
+
+ pub fn set_frame_rate(&mut self, frame_rate: Option<FrameRate>) {
+ self.frame_rate = frame_rate;
+ }
+
+ pub fn play(&mut self, wall_seconds: f64) -> Vec<PlaybackEvent> {
+ if self.total_duration <= MediaTime::ZERO {
+ return Vec::new();
+ }
+ let mut events = Vec::new();
+ if self.current_time >= self.total_duration {
+ self.current_time = MediaTime::ZERO;
+ events.push(PlaybackEvent::Seeked(MediaTime::ZERO));
+ }
+ self.is_playing = true;
+ self.anchor_media = self.current_time;
+ self.anchor_wall_seconds = wall_seconds;
+ events.push(PlaybackEvent::Started);
+ events
+ }
+
+ pub fn pause(&mut self) -> Vec<PlaybackEvent> {
+ if !self.is_playing {
+ return Vec::new();
+ }
+ self.is_playing = false;
+ vec![PlaybackEvent::Paused]
+ }
+
+ pub fn toggle(&mut self, wall_seconds: f64) -> Vec<PlaybackEvent> {
+ if self.is_playing {
+ self.pause()
+ } else {
+ self.play(wall_seconds)
+ }
+ }
+
+ pub fn seek(&mut self, time: MediaTime, wall_seconds: f64) -> Vec<PlaybackEvent> {
+ self.current_time = self.clamp(time);
+ if self.is_playing {
+ self.anchor_media = self.current_time;
+ self.anchor_wall_seconds = wall_seconds;
+ }
+ vec![PlaybackEvent::Seeked(self.current_time)]
+ }
+
+ pub fn tick(&mut self, wall_seconds: f64) -> Vec<PlaybackEvent> {
+ if !self.is_playing {
+ return Vec::new();
+ }
+ let elapsed = wall_seconds - self.anchor_wall_seconds;
+ let Some(elapsed_time) = MediaTime::from_seconds_f64(elapsed) else {
+ return Vec::new();
+ };
+ let raw = MediaTime::from_ticks(
+ self.anchor_media.as_ticks().saturating_add(elapsed_time.as_ticks()),
+ );
+ let rounded = self.round_to_frame(raw);
+
+ if rounded >= self.total_duration {
+ self.is_playing = false;
+ self.current_time = self.total_duration;
+ return vec![
+ PlaybackEvent::Paused,
+ PlaybackEvent::ReachedEnd(self.total_duration),
+ ];
+ }
+ if rounded == self.current_time {
+ return Vec::new();
+ }
+ self.current_time = rounded;
+ vec![PlaybackEvent::Updated(rounded)]
+ }
+
+ pub fn set_volume(&mut self, volume: f64) {
+ let clamped = volume.clamp(0.0, 1.0);
+ self.volume = clamped;
+ self.muted = clamped == 0.0;
+ if clamped > 0.0 {
+ self.previous_volume = clamped;
+ }
+ }
+
+ pub fn mute(&mut self) {
+ if self.volume > 0.0 {
+ self.previous_volume = self.volume;
+ }
+ self.muted = true;
+ self.volume = 0.0;
+ }
+
+ pub fn unmute(&mut self) {
+ self.muted = false;
+ self.volume = self.previous_volume;
+ }
+
+ pub fn toggle_mute(&mut self) {
+ if self.muted {
+ self.unmute();
+ } else {
+ self.mute();
+ }
+ }
+
+ pub fn set_scrubbing(&mut self, is_scrubbing: bool) {
+ self.is_scrubbing = is_scrubbing;
+ }
+
+ fn clamp(&self, time: MediaTime) -> MediaTime {
+ time.clamp(MediaTime::ZERO, self.total_duration)
+ }
+
+ fn round_to_frame(&self, time: MediaTime) -> MediaTime {
+ let Some(rate) = self.frame_rate else {
+ return time;
+ };
+ let Some(frame) = time.to_frame_round(rate) else {
+ return time;
+ };
+ MediaTime::from_frame(frame, rate).unwrap_or(time)
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ const FPS_30: FrameRate = FrameRate {
+ numerator: 30,
+ denominator: 1,
+ };
+
+ fn clock() -> PlaybackClock {
+ let mut clock = PlaybackClock::new();
+ clock.set_frame_rate(Some(FPS_30));
+ clock.set_total_duration(MediaTime::from_seconds_f64(4.0).unwrap());
+ clock
+ }
+
+ #[test]
+ fn play_at_end_restarts_from_zero() {
+ let mut clock = clock();
+ clock.seek(MediaTime::from_seconds_f64(10.0).unwrap(), 0.0);
+ let events = clock.play(0.0);
+ assert!(events.contains(&PlaybackEvent::Seeked(MediaTime::ZERO)));
+ assert!(events.contains(&PlaybackEvent::Started));
+ }
+
+ #[test]
+ fn play_with_empty_timeline_does_nothing() {
+ let mut clock = PlaybackClock::new();
+ assert!(clock.play(0.0).is_empty());
+ assert!(!clock.is_playing());
+ }
+
+ #[test]
+ fn tick_rounds_to_frames() {
+ let mut clock = clock();
+ clock.play(0.0);
+ let events = clock.tick(0.5);
+ let expected = MediaTime::from_frame(15, FPS_30).unwrap();
+ assert_eq!(events, vec![PlaybackEvent::Updated(expected)]);
+ assert_eq!(clock.current_time(), expected);
+ }
+
+ #[test]
+ fn tick_stops_at_end() {
+ let mut clock = clock();
+ clock.play(0.0);
+ let events = clock.tick(10.0);
+ assert!(events.contains(&PlaybackEvent::Paused));
+ assert_eq!(clock.current_time(), clock.total_duration());
+ assert!(!clock.is_playing());
+ }
+
+ #[test]
+ fn tick_same_frame_emits_nothing() {
+ let mut clock = clock();
+ clock.play(0.0);
+ assert!(clock.tick(0.001).is_empty());
+ }
+
+ #[test]
+ fn seek_clamps_into_timeline() {
+ let mut clock = clock();
+ clock.seek(MediaTime::from_seconds_f64(99.0).unwrap(), 0.0);
+ assert_eq!(clock.current_time(), clock.total_duration());
+ }
+
+ #[test]
+ fn seek_while_playing_reanchors() {
+ let mut clock = clock();
+ clock.play(0.0);
+ clock.seek(MediaTime::from_seconds_f64(1.0).unwrap(), 5.0);
+ let events = clock.tick(6.0);
+ let expected = MediaTime::from_frame(60, FPS_30).unwrap();
+ assert_eq!(events, vec![PlaybackEvent::Updated(expected)]);
+ }
+
+ #[test]
+ fn volume_mute_cycle_restores_previous() {
+ let mut clock = clock();
+ clock.set_volume(0.7);
+ clock.mute();
+ assert_eq!(clock.volume(), 0.0);
+ assert!(clock.is_muted());
+ clock.unmute();
+ assert_eq!(clock.volume(), 0.7);
+ assert!(!clock.is_muted());
+ }
+
+ #[test]
+ fn zero_volume_counts_as_muted() {
+ let mut clock = clock();
+ clock.set_volume(0.5);
+ clock.set_volume(0.0);
+ assert!(clock.is_muted());
+ clock.unmute();
+ assert_eq!(clock.volume(), 0.5);
+ }
+
+ #[test]
+ fn shrinking_duration_pauses_at_end() {
+ let mut clock = clock();
+ clock.play(0.0);
+ clock.tick(2.0);
+ let events = clock.set_total_duration(MediaTime::from_seconds_f64(1.0).unwrap());
+ assert!(events.contains(&PlaybackEvent::Paused));
+ assert_eq!(clock.current_time(), MediaTime::from_seconds_f64(1.0).unwrap());
+ }
+}
diff --git a/rust/crates/scene/Cargo.toml b/rust/crates/scene/Cargo.toml
new file mode 100644
index 00000000..f81e1457
--- /dev/null
+++ b/rust/crates/scene/Cargo.toml
@@ -0,0 +1,10 @@
+[package]
+name = "scene"
+version = "0.1.0"
+edition = "2024"
+
+[dependencies]
+chrono = { version = "0.4", features = ["serde"] }
+serde = { version = "1.0", features = ["derive"] }
+serde_json = "1.0"
+time = { version = "0.1.0", path = "../time" }
diff --git a/rust/crates/scene/src/animation.rs b/rust/crates/scene/src/animation.rs
new file mode 100644
index 00000000..d09dd3de
--- /dev/null
+++ b/rust/crates/scene/src/animation.rs
@@ -0,0 +1,127 @@
+use serde::{Deserialize, Serialize};
+use std::collections::BTreeMap;
+use time::MediaTime;
+
+use crate::params::ParamValue;
+
+pub const ANIMATION_PROPERTY_PATHS: &[&str] = &[
+ "transform.positionX",
+ "transform.positionY",
+ "transform.scaleX",
+ "transform.scaleY",
+ "transform.rotate",
+ "opacity",
+ "volume",
+ "color",
+ "background.color",
+ "background.paddingX",
+ "background.paddingY",
+ "background.offsetX",
+ "background.offsetY",
+ "background.cornerRadius",
+];
+
+#[derive(Serialize, Deserialize, Clone, Copy, Debug, Eq, PartialEq)]
+#[serde(rename_all = "camelCase")]
+pub enum ScalarSegmentType {
+ Step,
+ Linear,
+ Bezier,
+}
+
+#[derive(Serialize, Deserialize, Clone, Copy, Debug, Eq, PartialEq)]
+#[serde(rename_all = "camelCase")]
+pub enum TangentMode {
+ Auto,
+ Aligned,
+ Broken,
+ Flat,
+}
+
+#[derive(Serialize, Deserialize, Clone, Copy, Debug, Eq, PartialEq)]
+#[serde(rename_all = "camelCase")]
+pub enum ChannelExtrapolationMode {
+ Hold,
+ Linear,
+}
+
+#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq)]
+pub struct CurveHandle {
+ pub dt: MediaTime,
+ pub dv: f64,
+}
+
+#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
+#[serde(rename_all = "camelCase")]
+pub struct ScalarAnimationKey {
+ pub id: String,
+ pub time: MediaTime,
+ pub value: f64,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub left_handle: Option<CurveHandle>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub right_handle: Option<CurveHandle>,
+ pub segment_to_next: ScalarSegmentType,
+ pub tangent_mode: TangentMode,
+}
+
+#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
+#[serde(untagged)]
+pub enum DiscreteValue {
+ Bool(bool),
+ String(String),
+}
+
+#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
+#[serde(rename_all = "camelCase")]
+pub struct DiscreteAnimationKey {
+ pub id: String,
+ pub time: MediaTime,
+ pub value: DiscreteValue,
+}
+
+#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
+#[serde(rename_all = "camelCase")]
+pub struct ChannelExtrapolation {
+ pub before: ChannelExtrapolationMode,
+ pub after: ChannelExtrapolationMode,
+}
+
+#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
+pub struct ScalarChannel {
+ pub keys: Vec<ScalarAnimationKey>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub extrapolation: Option<ChannelExtrapolation>,
+}
+
+#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
+pub struct DiscreteChannel {
+ pub keys: Vec<DiscreteAnimationKey>,
+}
+
+#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
+#[serde(untagged)]
+pub enum AnimationChannel {
+ Scalar(ScalarChannel),
+ Discrete(DiscreteChannel),
+}
+
+pub type CompositeChannelData = BTreeMap<String, AnimationChannel>;
+
+#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
+#[serde(untagged)]
+pub enum ChannelData {
+ Single(AnimationChannel),
+ Composite(CompositeChannelData),
+}
+
+pub type ElementAnimations = BTreeMap<String, ChannelData>;
+
+impl From<DiscreteValue> for ParamValue {
+ fn from(value: DiscreteValue) -> Self {
+ match value {
+ DiscreteValue::Bool(b) => ParamValue::Bool(b),
+ DiscreteValue::String(s) => ParamValue::String(s),
+ }
+ }
+}
diff --git a/rust/crates/scene/src/effect.rs b/rust/crates/scene/src/effect.rs
new file mode 100644
index 00000000..f15a6f60
--- /dev/null
+++ b/rust/crates/scene/src/effect.rs
@@ -0,0 +1,13 @@
+use serde::{Deserialize, Serialize};
+
+use crate::params::ParamValues;
+
+#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
+pub struct Effect {
+ pub id: String,
+ #[serde(rename = "type")]
+ pub effect_type: String,
+ #[serde(default)]
+ pub params: ParamValues,
+ pub enabled: bool,
+}
diff --git a/rust/crates/scene/src/element.rs b/rust/crates/scene/src/element.rs
new file mode 100644
index 00000000..ef6980f8
--- /dev/null
+++ b/rust/crates/scene/src/element.rs
@@ -0,0 +1,187 @@
+use serde::{Deserialize, Serialize};
+use time::MediaTime;
+
+use crate::animation::ElementAnimations;
+use crate::effect::Effect;
+use crate::mask::Mask;
+use crate::params::ParamValues;
+
+#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
+#[serde(rename_all = "camelCase")]
+pub struct ElementRef {
+ pub track_id: String,
+ pub element_id: String,
+}
+
+#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq)]
+#[serde(rename_all = "camelCase")]
+pub struct RetimeConfig {
+ pub rate: f64,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub maintain_pitch: Option<bool>,
+}
+
+#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
+#[serde(rename_all = "camelCase")]
+pub struct BaseTimelineElement {
+ pub id: String,
+ pub name: String,
+ pub duration: MediaTime,
+ pub start_time: MediaTime,
+ pub trim_start: MediaTime,
+ pub trim_end: MediaTime,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub source_duration: Option<MediaTime>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub animations: Option<ElementAnimations>,
+ #[serde(default)]
+ pub params: ParamValues,
+}
+
+#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
+#[serde(rename_all = "camelCase")]
+pub struct VideoElement {
+ #[serde(flatten)]
+ pub base: BaseTimelineElement,
+ pub media_id: String,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub is_source_audio_enabled: Option<bool>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub hidden: Option<bool>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub retime: Option<RetimeConfig>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub effects: Option<Vec<Effect>>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub masks: Option<Vec<Mask>>,
+}
+
+#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
+#[serde(rename_all = "camelCase")]
+pub struct ImageElement {
+ #[serde(flatten)]
+ pub base: BaseTimelineElement,
+ pub media_id: String,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub hidden: Option<bool>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub effects: Option<Vec<Effect>>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub masks: Option<Vec<Mask>>,
+}
+
+#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
+#[serde(rename_all = "camelCase")]
+pub struct TextElement {
+ #[serde(flatten)]
+ pub base: BaseTimelineElement,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub hidden: Option<bool>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub effects: Option<Vec<Effect>>,
+}
+
+#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
+#[serde(rename_all = "camelCase")]
+pub struct StickerElement {
+ #[serde(flatten)]
+ pub base: BaseTimelineElement,
+ pub sticker_id: String,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub intrinsic_width: Option<f64>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub intrinsic_height: Option<f64>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub hidden: Option<bool>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub effects: Option<Vec<Effect>>,
+}
+
+#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
+#[serde(rename_all = "camelCase")]
+pub struct GraphicElement {
+ #[serde(flatten)]
+ pub base: BaseTimelineElement,
+ pub definition_id: String,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub hidden: Option<bool>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub effects: Option<Vec<Effect>>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub masks: Option<Vec<Mask>>,
+}
+
+#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
+#[serde(rename_all = "camelCase")]
+pub struct EffectElement {
+ #[serde(flatten)]
+ pub base: BaseTimelineElement,
+ pub effect_type: String,
+}
+
+#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
+#[serde(rename_all = "camelCase")]
+pub struct UploadAudioElement {
+ #[serde(flatten)]
+ pub base: BaseTimelineElement,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub retime: Option<RetimeConfig>,
+ pub media_id: String,
+}
+
+#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
+#[serde(rename_all = "camelCase")]
+pub struct LibraryAudioElement {
+ #[serde(flatten)]
+ pub base: BaseTimelineElement,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub retime: Option<RetimeConfig>,
+ pub source_url: String,
+}
+
+#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
+#[serde(tag = "sourceType", rename_all = "camelCase")]
+pub enum AudioElement {
+ Upload(UploadAudioElement),
+ Library(LibraryAudioElement),
+}
+
+#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
+#[serde(tag = "type", rename_all = "camelCase")]
+pub enum Element {
+ Audio(AudioElement),
+ Video(VideoElement),
+ Image(ImageElement),
+ Text(TextElement),
+ Sticker(StickerElement),
+ Graphic(GraphicElement),
+ Effect(EffectElement),
+}
+
+impl Element {
+ pub fn base(&self) -> &BaseTimelineElement {
+ match self {
+ Element::Audio(AudioElement::Upload(e)) => &e.base,
+ Element::Audio(AudioElement::Library(e)) => &e.base,
+ Element::Video(e) => &e.base,
+ Element::Image(e) => &e.base,
+ Element::Text(e) => &e.base,
+ Element::Sticker(e) => &e.base,
+ Element::Graphic(e) => &e.base,
+ Element::Effect(e) => &e.base,
+ }
+ }
+
+ pub fn base_mut(&mut self) -> &mut BaseTimelineElement {
+ match self {
+ Element::Audio(AudioElement::Upload(e)) => &mut e.base,
+ Element::Audio(AudioElement::Library(e)) => &mut e.base,
+ Element::Video(e) => &mut e.base,
+ Element::Image(e) => &mut e.base,
+ Element::Text(e) => &mut e.base,
+ Element::Sticker(e) => &mut e.base,
+ Element::Graphic(e) => &mut e.base,
+ Element::Effect(e) => &mut e.base,
+ }
+ }
+}
diff --git a/rust/crates/scene/src/iso_date.rs b/rust/crates/scene/src/iso_date.rs
new file mode 100644
index 00000000..82fb7828
--- /dev/null
+++ b/rust/crates/scene/src/iso_date.rs
@@ -0,0 +1,19 @@
+use serde::{Deserialize, Deserializer, Serializer};
+use chrono::{DateTime, SecondsFormat, Utc};
+
+pub fn serialize<S>(date: &DateTime<Utc>, serializer: S) -> Result<S::Ok, S::Error>
+where
+ S: Serializer,
+{
+ serializer.serialize_str(&date.to_rfc3339_opts(SecondsFormat::Millis, true))
+}
+
+pub fn deserialize<'de, D>(deserializer: D) -> Result<DateTime<Utc>, D::Error>
+where
+ D: Deserializer<'de>,
+{
+ let s = String::deserialize(deserializer)?;
+ DateTime::parse_from_rfc3339(&s)
+ .map(|d| d.with_timezone(&Utc))
+ .map_err(serde::de::Error::custom)
+}
diff --git a/rust/crates/scene/src/lib.rs b/rust/crates/scene/src/lib.rs
new file mode 100644
index 00000000..d18925ff
--- /dev/null
+++ b/rust/crates/scene/src/lib.rs
@@ -0,0 +1,18 @@
+pub mod animation;
+pub mod effect;
+pub mod element;
+pub mod iso_date;
+pub mod mask;
+pub mod params;
+pub mod project;
+pub mod scene;
+pub mod track;
+
+pub use animation::*;
+pub use effect::*;
+pub use element::*;
+pub use mask::*;
+pub use params::*;
+pub use project::*;
+pub use scene::*;
+pub use track::*;
diff --git a/rust/crates/scene/src/mask.rs b/rust/crates/scene/src/mask.rs
new file mode 100644
index 00000000..12f026d2
--- /dev/null
+++ b/rust/crates/scene/src/mask.rs
@@ -0,0 +1,128 @@
+use serde::{Deserialize, Serialize};
+
+#[derive(Serialize, Deserialize, Clone, Copy, Debug, Eq, PartialEq)]
+#[serde(rename_all = "camelCase")]
+pub enum StrokeAlign {
+ Inside,
+ Center,
+ Outside,
+}
+
+#[derive(Serialize, Deserialize, Clone, Copy, Debug, Eq, PartialEq)]
+pub enum TextFontWeight {
+ #[serde(rename = "normal")]
+ Normal,
+ #[serde(rename = "bold")]
+ Bold,
+}
+
+#[derive(Serialize, Deserialize, Clone, Copy, Debug, Eq, PartialEq)]
+pub enum TextFontStyle {
+ #[serde(rename = "normal")]
+ Normal,
+ #[serde(rename = "italic")]
+ Italic,
+}
+
+#[derive(Serialize, Deserialize, Clone, Copy, Debug, Eq, PartialEq)]
+pub enum TextDecoration {
+ #[serde(rename = "none")]
+ None,
+ #[serde(rename = "underline")]
+ Underline,
+ #[serde(rename = "line-through")]
+ LineThrough,
+}
+
+#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
+#[serde(rename_all = "camelCase")]
+pub struct FreeformPathPoint {
+ pub id: String,
+ pub x: f64,
+ pub y: f64,
+ pub in_x: f64,
+ pub in_y: f64,
+ pub out_x: f64,
+ pub out_y: f64,
+}
+
+#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
+#[serde(rename_all = "camelCase")]
+pub struct SplitMaskParams {
+ pub feather: f64,
+ pub inverted: bool,
+ pub stroke_color: String,
+ pub stroke_width: f64,
+ pub stroke_align: StrokeAlign,
+ pub center_x: f64,
+ pub center_y: f64,
+ pub rotation: f64,
+}
+
+#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
+#[serde(rename_all = "camelCase")]
+pub struct RectangleMaskParams {
+ pub feather: f64,
+ pub inverted: bool,
+ pub stroke_color: String,
+ pub stroke_width: f64,
+ pub stroke_align: StrokeAlign,
+ pub center_x: f64,
+ pub center_y: f64,
+ pub width: f64,
+ pub height: f64,
+ pub rotation: f64,
+ pub scale: f64,
+}
+
+#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
+#[serde(rename_all = "camelCase")]
+pub struct TextMaskParams {
+ pub feather: f64,
+ pub inverted: bool,
+ pub stroke_color: String,
+ pub stroke_width: f64,
+ pub stroke_align: StrokeAlign,
+ pub content: String,
+ pub font_size: f64,
+ pub font_family: String,
+ pub font_weight: TextFontWeight,
+ pub font_style: TextFontStyle,
+ pub text_decoration: TextDecoration,
+ pub letter_spacing: f64,
+ pub line_height: f64,
+ pub center_x: f64,
+ pub center_y: f64,
+ pub rotation: f64,
+ pub scale: f64,
+}
+
+#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
+#[serde(rename_all = "camelCase")]
+pub struct FreeformPathMaskParams {
+ pub feather: f64,
+ pub inverted: bool,
+ pub stroke_color: String,
+ pub stroke_width: f64,
+ pub stroke_align: StrokeAlign,
+ pub path: Vec<FreeformPathPoint>,
+ pub closed: bool,
+ pub center_x: f64,
+ pub center_y: f64,
+ pub rotation: f64,
+ pub scale: f64,
+}
+
+#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
+#[serde(tag = "type", rename_all = "kebab-case")]
+pub enum Mask {
+ Split { id: String, params: SplitMaskParams },
+ CinematicBars { id: String, params: RectangleMaskParams },
+ Rectangle { id: String, params: RectangleMaskParams },
+ Ellipse { id: String, params: RectangleMaskParams },
+ Heart { id: String, params: RectangleMaskParams },
+ Diamond { id: String, params: RectangleMaskParams },
+ Star { id: String, params: RectangleMaskParams },
+ Text { id: String, params: TextMaskParams },
+ Freeform { id: String, params: FreeformPathMaskParams },
+}
diff --git a/rust/crates/scene/src/params.rs b/rust/crates/scene/src/params.rs
new file mode 100644
index 00000000..e59c16b5
--- /dev/null
+++ b/rust/crates/scene/src/params.rs
@@ -0,0 +1,12 @@
+use serde::{Deserialize, Serialize};
+use std::collections::BTreeMap;
+
+#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
+#[serde(untagged)]
+pub enum ParamValue {
+ Number(f64),
+ String(String),
+ Bool(bool),
+}
+
+pub type ParamValues = BTreeMap<String, ParamValue>;
diff --git a/rust/crates/scene/src/project.rs b/rust/crates/scene/src/project.rs
new file mode 100644
index 00000000..6af6c3cc
--- /dev/null
+++ b/rust/crates/scene/src/project.rs
@@ -0,0 +1,76 @@
+use chrono::{DateTime, Utc};
+use serde::{Deserialize, Serialize};
+use time::{FrameRate, MediaTime};
+
+use crate::iso_date;
+use crate::scene::Scene;
+
+#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
+#[serde(tag = "type", rename_all = "camelCase", rename_all_fields = "camelCase")]
+pub enum Background {
+ Color { color: String },
+ Blur { blur_intensity: f64 },
+}
+
+#[derive(Serialize, Deserialize, Clone, Copy, Debug, Eq, PartialEq)]
+#[serde(rename_all = "camelCase")]
+pub struct CanvasSize {
+ pub width: u32,
+ pub height: u32,
+}
+
+#[derive(Serialize, Deserialize, Clone, Copy, Debug, Eq, PartialEq)]
+#[serde(rename_all = "camelCase")]
+pub enum CanvasSizeMode {
+ Preset,
+ Custom,
+}
+
+#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
+#[serde(rename_all = "camelCase")]
+pub struct ProjectMetadata {
+ pub id: String,
+ pub name: String,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub thumbnail: Option<String>,
+ pub duration: MediaTime,
+ #[serde(with = "iso_date")]
+ pub created_at: DateTime<Utc>,
+ #[serde(with = "iso_date")]
+ pub updated_at: DateTime<Utc>,
+}
+
+#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
+#[serde(rename_all = "camelCase")]
+pub struct ProjectSettings {
+ pub fps: FrameRate,
+ pub canvas_size: CanvasSize,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub canvas_size_mode: Option<CanvasSizeMode>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub last_custom_canvas_size: Option<CanvasSize>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub original_canvas_size: Option<CanvasSize>,
+ pub background: Background,
+}
+
+#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
+#[serde(rename_all = "camelCase")]
+pub struct TimelineViewState {
+ pub zoom_level: f64,
+ pub scroll_left: f64,
+ pub playhead_time: MediaTime,
+}
+
+#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
+#[serde(rename_all = "camelCase")]
+pub struct Project {
+ pub metadata: ProjectMetadata,
+ #[serde(default)]
+ pub scenes: Vec<Scene>,
+ pub current_scene_id: String,
+ pub settings: ProjectSettings,
+ pub version: u32,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub timeline_view_state: Option<TimelineViewState>,
+}
diff --git a/rust/crates/scene/src/scene.rs b/rust/crates/scene/src/scene.rs
new file mode 100644
index 00000000..ce59bce7
--- /dev/null
+++ b/rust/crates/scene/src/scene.rs
@@ -0,0 +1,33 @@
+use chrono::{DateTime, Utc};
+use serde::{Deserialize, Serialize};
+use time::MediaTime;
+
+use crate::iso_date;
+use crate::track::SceneTracks;
+
+#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
+#[serde(rename_all = "camelCase")]
+pub struct Bookmark {
+ pub time: MediaTime,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub note: Option<String>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub color: Option<String>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub duration: Option<MediaTime>,
+}
+
+#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
+#[serde(rename_all = "camelCase")]
+pub struct Scene {
+ pub id: String,
+ pub name: String,
+ pub is_main: bool,
+ pub tracks: SceneTracks,
+ #[serde(default)]
+ pub bookmarks: Vec<Bookmark>,
+ #[serde(with = "iso_date")]
+ pub created_at: DateTime<Utc>,
+ #[serde(with = "iso_date")]
+ pub updated_at: DateTime<Utc>,
+}
diff --git a/rust/crates/scene/src/track.rs b/rust/crates/scene/src/track.rs
new file mode 100644
index 00000000..b35a7b25
--- /dev/null
+++ b/rust/crates/scene/src/track.rs
@@ -0,0 +1,88 @@
+use serde::{Deserialize, Serialize};
+
+use crate::element::Element;
+
+#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
+#[serde(tag = "type", rename_all = "camelCase")]
+pub enum Track {
+ Video {
+ id: String,
+ name: String,
+ elements: Vec<Element>,
+ muted: bool,
+ hidden: bool,
+ },
+ Text {
+ id: String,
+ name: String,
+ elements: Vec<Element>,
+ hidden: bool,
+ },
+ Audio {
+ id: String,
+ name: String,
+ elements: Vec<Element>,
+ muted: bool,
+ },
+ Graphic {
+ id: String,
+ name: String,
+ elements: Vec<Element>,
+ hidden: bool,
+ },
+ Effect {
+ id: String,
+ name: String,
+ elements: Vec<Element>,
+ hidden: bool,
+ },
+}
+
+impl Track {
+ pub fn id(&self) -> &str {
+ match self {
+ Track::Video { id, .. }
+ | Track::Text { id, .. }
+ | Track::Audio { id, .. }
+ | Track::Graphic { id, .. }
+ | Track::Effect { id, .. } => id,
+ }
+ }
+
+ pub fn name(&self) -> &str {
+ match self {
+ Track::Video { name, .. }
+ | Track::Text { name, .. }
+ | Track::Audio { name, .. }
+ | Track::Graphic { name, .. }
+ | Track::Effect { name, .. } => name,
+ }
+ }
+
+ pub fn elements(&self) -> &[Element] {
+ match self {
+ Track::Video { elements, .. }
+ | Track::Text { elements, .. }
+ | Track::Audio { elements, .. }
+ | Track::Graphic { elements, .. }
+ | Track::Effect { elements, .. } => elements,
+ }
+ }
+
+ pub fn elements_mut(&mut self) -> &mut Vec<Element> {
+ match self {
+ Track::Video { elements, .. }
+ | Track::Text { elements, .. }
+ | Track::Audio { elements, .. }
+ | Track::Graphic { elements, .. }
+ | Track::Effect { elements, .. } => elements,
+ }
+ }
+}
+
+#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
+pub struct SceneTracks {
+ pub overlay: Vec<Track>,
+ pub main: Track,
+ pub audio: Vec<Track>,
+}
diff --git a/rust/crates/scene/tests/round_trip.rs b/rust/crates/scene/tests/round_trip.rs
new file mode 100644
index 00000000..8e26c1f7
--- /dev/null
+++ b/rust/crates/scene/tests/round_trip.rs
@@ -0,0 +1,190 @@
+#![recursion_limit = "256"]
+
+use ::scene::*;
+
+fn sample_project_json() -> serde_json::Value {
+ serde_json::json!({
+ "metadata": {
+ "id": "proj-1",
+ "name": "Demo",
+ "duration": 10000,
+ "createdAt": "2026-07-28T10:00:00.000Z",
+ "updatedAt": "2026-07-28T11:30:00.000Z"
+ },
+ "scenes": [{
+ "id": "scene-1",
+ "name": "Main scene",
+ "isMain": true,
+ "tracks": {
+ "overlay": [
+ {
+ "type": "text",
+ "id": "track-text",
+ "name": "Text",
+ "hidden": false,
+ "elements": [{
+ "type": "text",
+ "id": "el-text",
+ "name": "Title",
+ "duration": 2000,
+ "startTime": 0,
+ "trimStart": 0,
+ "trimEnd": 0,
+ "params": { "content": "Hello", "fontSize": 48, "bold": true },
+ "animations": {
+ "opacity": {
+ "keys": [
+ { "id": "k1", "time": 0, "value": 0, "segmentToNext": "linear", "tangentMode": "auto" },
+ { "id": "k2", "time": 500, "value": 1, "leftHandle": { "dt": -100, "dv": -0.1 }, "rightHandle": { "dt": 100, "dv": 0.1 }, "segmentToNext": "bezier", "tangentMode": "aligned" }
+ ],
+ "extrapolation": { "before": "hold", "after": "linear" }
+ },
+ "color": {
+ "r": { "keys": [{ "id": "k3", "time": 0, "value": 0.5, "segmentToNext": "step", "tangentMode": "flat" }] },
+ "g": { "keys": [{ "id": "k4", "time": 0, "value": 0.2, "segmentToNext": "linear", "tangentMode": "auto" }] }
+ }
+ },
+ "effects": [{ "id": "fx1", "type": "blur", "params": { "radius": 4 }, "enabled": true }]
+ }]
+ }
+ ],
+ "main": {
+ "type": "video",
+ "id": "track-main",
+ "name": "Main",
+ "muted": false,
+ "hidden": false,
+ "elements": [
+ {
+ "type": "video",
+ "id": "el-video",
+ "name": "Clip",
+ "duration": 5000,
+ "startTime": 0,
+ "trimStart": 100,
+ "trimEnd": 200,
+ "sourceDuration": 6000,
+ "mediaId": "media-1",
+ "isSourceAudioEnabled": true,
+ "retime": { "rate": 1.5, "maintainPitch": true },
+ "params": {},
+ "masks": [
+ { "id": "m1", "type": "rectangle", "params": { "feather": 2, "inverted": false, "strokeColor": "#fff", "strokeWidth": 1, "strokeAlign": "inside", "centerX": 0, "centerY": 0, "width": 100, "height": 50, "rotation": 0, "scale": 1 } },
+ { "id": "m2", "type": "freeform", "params": { "feather": 0, "inverted": true, "strokeColor": "#000", "strokeWidth": 0, "strokeAlign": "center", "path": [{ "id": "p1", "x": 0, "y": 0, "inX": -1, "inY": 0, "outX": 1, "outY": 0 }], "closed": true, "centerX": 10, "centerY": 20, "rotation": 15, "scale": 2 } },
+ { "id": "m3", "type": "cinematic-bars", "params": { "feather": 0, "inverted": false, "strokeColor": "#000", "strokeWidth": 0, "strokeAlign": "outside", "centerX": 0, "centerY": 0, "width": 1920, "height": 200, "rotation": 0, "scale": 1 } }
+ ]
+ },
+ {
+ "type": "image",
+ "id": "el-image",
+ "name": "Still",
+ "duration": 3000,
+ "startTime": 5000,
+ "trimStart": 0,
+ "trimEnd": 0,
+ "mediaId": "media-2",
+ "hidden": false,
+ "params": {}
+ }
+ ]
+ },
+ "audio": [
+ {
+ "type": "audio",
+ "id": "track-audio",
+ "name": "Audio",
+ "muted": false,
+ "elements": [
+ {
+ "type": "audio",
+ "sourceType": "upload",
+ "id": "el-au1",
+ "name": "Music",
+ "duration": 8000,
+ "startTime": 0,
+ "trimStart": 0,
+ "trimEnd": 0,
+ "mediaId": "media-3",
+ "retime": { "rate": 1 },
+ "params": { "volume": 0.8 }
+ },
+ {
+ "type": "audio",
+ "sourceType": "library",
+ "id": "el-au2",
+ "name": "Sfx",
+ "duration": 1000,
+ "startTime": 2000,
+ "trimStart": 0,
+ "trimEnd": 0,
+ "sourceUrl": "https://example.com/sfx.mp3",
+ "params": {}
+ }
+ ]
+ }
+ ]
+ },
+ "bookmarks": [{ "time": 1500, "note": "cut here", "color": "red", "duration": 250 }],
+ "createdAt": "2026-07-28T10:00:00.000Z",
+ "updatedAt": "2026-07-28T11:00:00.000Z"
+ }],
+ "currentSceneId": "scene-1",
+ "settings": {
+ "fps": { "numerator": 30, "denominator": 1 },
+ "canvasSize": { "width": 1920, "height": 1080 },
+ "canvasSizeMode": "preset",
+ "background": { "type": "blur", "blurIntensity": 8 }
+ },
+ "version": 7,
+ "timelineViewState": { "zoomLevel": 2, "scrollLeft": 120, "playheadTime": 750 }
+ })
+}
+
+fn diff_path(a: &serde_json::Value, b: &serde_json::Value, path: &str) {
+ match (a, b) {
+ (serde_json::Value::Object(x), serde_json::Value::Object(y)) => {
+ for key in x.keys().chain(y.keys()) {
+ if x.get(key) != y.get(key) {
+ diff_path(
+ x.get(key).unwrap_or(&serde_json::Value::Null),
+ y.get(key).unwrap_or(&serde_json::Value::Null),
+ &format!("{path}.{key}"),
+ );
+ return;
+ }
+ }
+ }
+ (serde_json::Value::Array(x), serde_json::Value::Array(y)) => {
+ for (i, (u, v)) in x.iter().zip(y.iter()).enumerate() {
+ if u != v {
+ diff_path(u, v, &format!("{path}[{i}]"));
+ return;
+ }
+ }
+ if x.len() != y.len() {
+ panic!("length mismatch at {path}: {} vs {}", x.len(), y.len());
+ }
+ }
+ (serde_json::Value::Number(x), serde_json::Value::Number(y))
+ if x.as_f64() == y.as_f64() => {}
+ _ => panic!("mismatch at {path}:\n serialized: {a}\n original: {b}"),
+ }
+}
+
+#[test]
+fn project_round_trip_preserves_json() {
+ let original = sample_project_json();
+ let project: Project = serde_json::from_value(original.clone()).expect("deserialize");
+ let serialized = serde_json::to_value(&project).expect("serialize");
+ diff_path(&serialized, &original, "$");
+}
+
+#[test]
+fn element_accessors_reach_base() {
+ let original = sample_project_json();
+ let project: Project = serde_json::from_value(original).expect("deserialize");
+ let main = &project.scenes[0].tracks.main;
+ let video = &main.elements()[0];
+ assert_eq!(video.base().id, "el-video");
+ assert_eq!(video.base().trim_start, time::MediaTime::from_ticks(100));
+}
diff --git a/rust/crates/storage/Cargo.toml b/rust/crates/storage/Cargo.toml
new file mode 100644
index 00000000..2ac5f67a
--- /dev/null
+++ b/rust/crates/storage/Cargo.toml
@@ -0,0 +1,16 @@
+[package]
+name = "storage"
+version = "0.1.0"
+edition = "2024"
+
+[dependencies]
+scene = { version = "0.1.0", path = "../scene" }
+transfer = { version = "0.1.0", path = "../transfer" }
+serde = { version = "1.0", features = ["derive"] }
+serde_json = "1.0"
+thiserror = "2"
+
+[dev-dependencies]
+tempfile = "3"
+chrono = { version = "0.4", features = ["serde"] }
+time = { version = "0.1.0", path = "../time" }
diff --git a/rust/crates/storage/src/lib.rs b/rust/crates/storage/src/lib.rs
new file mode 100644
index 00000000..981f3ad1
--- /dev/null
+++ b/rust/crates/storage/src/lib.rs
@@ -0,0 +1,256 @@
+use std::fs;
+use std::io::ErrorKind;
+use std::path::{Path, PathBuf};
+
+use scene::{Project, ProjectMetadata};
+use transfer::{
+ ArchiveMediaInput, ArchivedMediaMetadata, MEDIA_ENTRY_PREFIX, ProjectArchiveError,
+};
+
+const PROJECT_FILE: &str = "project.json";
+const MEDIA_DIR: &str = "media";
+const MANIFEST_FILE: &str = "manifest.json";
+
+#[derive(Debug, thiserror::Error)]
+pub enum StorageError {
+ #[error("project not found: {0}")]
+ ProjectNotFound(String),
+ #[error("project already exists: {0}")]
+ ProjectExists(String),
+ #[error("media not found: {0}")]
+ MediaNotFound(String),
+ #[error(transparent)]
+ Io(#[from] std::io::Error),
+ #[error(transparent)]
+ Json(#[from] serde_json::Error),
+ #[error(transparent)]
+ Archive(#[from] ProjectArchiveError),
+}
+
+pub type Result<T, E = StorageError> = std::result::Result<T, E>;
+
+pub struct ProjectStore {
+ root: PathBuf,
+}
+
+impl ProjectStore {
+ pub fn new(root: PathBuf) -> Self {
+ Self { root }
+ }
+
+ pub fn root(&self) -> &Path {
+ &self.root
+ }
+
+ pub fn project_dir(&self, project_id: &str) -> PathBuf {
+ self.root.join(project_id)
+ }
+
+ pub fn create_project(&self, project: &Project) -> Result<()> {
+ let dir = self.project_dir(&project.metadata.id);
+ if dir.exists() {
+ return Err(StorageError::ProjectExists(project.metadata.id.clone()));
+ }
+ fs::create_dir_all(dir.join(MEDIA_DIR))?;
+ self.save_project(project)
+ }
+
+ pub fn save_project(&self, project: &Project) -> Result<()> {
+ let dir = self.project_dir(&project.metadata.id);
+ fs::create_dir_all(dir.join(MEDIA_DIR))?;
+ let json = serde_json::to_vec_pretty(project)?;
+ write_atomic(&dir.join(PROJECT_FILE), &json)
+ }
+
+ pub fn load_project(&self, project_id: &str) -> Result<Project> {
+ let path = self.project_dir(project_id).join(PROJECT_FILE);
+ let bytes = fs::read(&path).map_err(|error| match error.kind() {
+ ErrorKind::NotFound => StorageError::ProjectNotFound(project_id.to_string()),
+ _ => StorageError::Io(error),
+ })?;
+ Ok(serde_json::from_slice(&bytes)?)
+ }
+
+ pub fn delete_project(&self, project_id: &str) -> Result<()> {
+ let dir = self.project_dir(project_id);
+ fs::remove_dir_all(&dir).map_err(|error| match error.kind() {
+ ErrorKind::NotFound => StorageError::ProjectNotFound(project_id.to_string()),
+ _ => StorageError::Io(error),
+ })
+ }
+
+ pub fn list_projects(&self) -> Result<Vec<ProjectMetadata>> {
+ fs::create_dir_all(&self.root)?;
+ let mut metadata = Vec::new();
+ for entry in fs::read_dir(&self.root)? {
+ let entry = entry?;
+ if !entry.file_type()?.is_dir() {
+ continue;
+ }
+ let path = entry.path().join(PROJECT_FILE);
+ match fs::read(&path) {
+ Ok(bytes) => {
+ if let Ok(project) = serde_json::from_slice::<Project>(&bytes) {
+ metadata.push(project.metadata);
+ }
+ }
+ Err(error) if error.kind() == ErrorKind::NotFound => continue,
+ Err(error) => return Err(error.into()),
+ }
+ }
+ metadata.sort_by(|a, b| b.updated_at.cmp(&a.updated_at));
+ Ok(metadata)
+ }
+
+ pub fn add_media(
+ &self,
+ project_id: &str,
+ metadata: &ArchivedMediaMetadata,
+ data: &[u8],
+ ) -> Result<()> {
+ let media_dir = self.media_dir(project_id)?;
+ let mut manifest = self.read_manifest(project_id)?;
+ if let Some(existing) = manifest.iter().find(|item| item.id == metadata.id) {
+ let previous = media_dir.join(media_file_name(existing));
+ let next = media_dir.join(media_file_name(metadata));
+ if previous != next {
+ let _ = fs::remove_file(previous);
+ }
+ manifest.retain(|item| item.id != metadata.id);
+ }
+ write_atomic(&media_dir.join(media_file_name(metadata)), data)?;
+ manifest.push(metadata.clone());
+ self.write_manifest(project_id, &manifest)
+ }
+
+ pub fn media_path(&self, project_id: &str, media_id: &str) -> Result<PathBuf> {
+ let media_dir = self.media_dir(project_id)?;
+ let manifest = self.read_manifest(project_id)?;
+ let metadata = manifest
+ .iter()
+ .find(|item| item.id == media_id)
+ .ok_or_else(|| StorageError::MediaNotFound(media_id.to_string()))?;
+ Ok(media_dir.join(media_file_name(metadata)))
+ }
+
+ pub fn delete_media(&self, project_id: &str, media_id: &str) -> Result<()> {
+ let media_dir = self.media_dir(project_id)?;
+ let mut manifest = self.read_manifest(project_id)?;
+ let Some(position) = manifest.iter().position(|item| item.id == media_id) else {
+ return Err(StorageError::MediaNotFound(media_id.to_string()));
+ };
+ let metadata = manifest.remove(position);
+ let path = media_dir.join(media_file_name(&metadata));
+ match fs::remove_file(&path) {
+ Ok(()) => {}
+ Err(error) if error.kind() == ErrorKind::NotFound => {}
+ Err(error) => return Err(error.into()),
+ }
+ self.write_manifest(project_id, &manifest)
+ }
+
+ pub fn list_media(&self, project_id: &str) -> Result<Vec<ArchivedMediaMetadata>> {
+ self.media_dir(project_id)?;
+ self.read_manifest(project_id)
+ }
+
+ pub fn export_ocp(&self, project_id: &str) -> Result<Vec<u8>> {
+ let project = self.load_project(project_id)?;
+ let project_json = serde_json::to_string(&project)?;
+ let media_dir = self.media_dir(project_id)?;
+ let manifest = self.read_manifest(project_id)?;
+ let mut media = Vec::with_capacity(manifest.len());
+ for metadata in manifest {
+ let data = fs::read(media_dir.join(media_file_name(&metadata)))?;
+ media.push(ArchiveMediaInput { metadata, data });
+ }
+ Ok(transfer::build_project_archive(&project_json, media)?)
+ }
+
+ pub fn import_ocp(&self, bytes: &[u8]) -> Result<String> {
+ let parsed = transfer::parse_project_archive(bytes)?;
+ let mut project: Project =
+ serde_json::from_value(serde_json::Value::Object(parsed.project.clone()))?;
+
+ let base_id = project.metadata.id.clone();
+ let mut candidate = base_id.clone();
+ let mut suffix = 1;
+ while self.project_dir(&candidate).exists() {
+ candidate = format!("{base_id}-{suffix}");
+ suffix += 1;
+ }
+ project.metadata.id = candidate.clone();
+
+ let import = (|| -> Result<()> {
+ self.create_project(&project)?;
+ let media_dir = self.media_dir(&candidate)?;
+ let mut manifest = Vec::with_capacity(parsed.media.len());
+ for entry in &parsed.media {
+ let data = parsed.read_media_data(&entry.entry)?;
+ let file_name = media_file_name(&entry.metadata);
+ write_atomic(&media_dir.join(file_name), data)?;
+ manifest.push(entry.metadata.clone());
+ }
+ self.write_manifest(&candidate, &manifest)
+ })();
+
+ if let Err(error) = import {
+ let _ = fs::remove_dir_all(self.project_dir(&candidate));
+ return Err(error);
+ }
+ Ok(candidate)
+ }
+
+ fn media_dir(&self, project_id: &str) -> Result<PathBuf> {
+ let dir = self.project_dir(project_id);
+ if !dir.is_dir() {
+ return Err(StorageError::ProjectNotFound(project_id.to_string()));
+ }
+ Ok(dir.join(MEDIA_DIR))
+ }
+
+ fn read_manifest(&self, project_id: &str) -> Result<Vec<ArchivedMediaMetadata>> {
+ let path = self
+ .project_dir(project_id)
+ .join(MEDIA_DIR)
+ .join(MANIFEST_FILE);
+ match fs::read(&path) {
+ Ok(bytes) => Ok(serde_json::from_slice(&bytes)?),
+ Err(error) if error.kind() == ErrorKind::NotFound => Ok(Vec::new()),
+ Err(error) => Err(error.into()),
+ }
+ }
+
+ fn write_manifest(
+ &self,
+ project_id: &str,
+ manifest: &[ArchivedMediaMetadata],
+ ) -> Result<()> {
+ let json = serde_json::to_vec_pretty(manifest)?;
+ write_atomic(
+ &self
+ .project_dir(project_id)
+ .join(MEDIA_DIR)
+ .join(MANIFEST_FILE),
+ &json,
+ )
+ }
+}
+
+fn media_file_name(metadata: &ArchivedMediaMetadata) -> String {
+ transfer::build_media_entry_name(&metadata.id, &metadata.name)
+ .trim_start_matches(MEDIA_ENTRY_PREFIX)
+ .to_string()
+}
+
+fn write_atomic(path: &Path, data: &[u8]) -> Result<()> {
+ let mut tmp_name = path
+ .file_name()
+ .map(std::ffi::OsString::from)
+ .unwrap_or_else(|| std::ffi::OsString::from("tmp"));
+ tmp_name.push(".tmp");
+ let tmp = path.with_file_name(tmp_name);
+ fs::write(&tmp, data)?;
+ fs::rename(&tmp, path)?;
+ Ok(())
+}
diff --git a/rust/crates/storage/tests/store.rs b/rust/crates/storage/tests/store.rs
new file mode 100644
index 00000000..696ddde8
--- /dev/null
+++ b/rust/crates/storage/tests/store.rs
@@ -0,0 +1,276 @@
+use chrono::{DateTime, Utc};
+use scene::{
+ Background, BaseTimelineElement, CanvasSize, Element, Project, ProjectMetadata,
+ ProjectSettings, Scene, SceneTracks, Track, VideoElement,
+};
+use storage::ProjectStore;
+use tempfile::TempDir;
+use time::{FrameRate, MediaTime};
+use transfer::ArchivedMediaMetadata;
+
+fn date(day: u8) -> DateTime<Utc> {
+ DateTime::parse_from_rfc3339(&format!("2026-01-{day:02}T00:00:00Z"))
+ .unwrap()
+ .with_timezone(&Utc)
+}
+
+fn seconds(value: f64) -> MediaTime {
+ MediaTime::from_seconds_f64(value).unwrap()
+}
+
+fn sample_project(id: &str) -> Project {
+ let scene_id = format!("{id}-scene");
+ let element = Element::Video(VideoElement {
+ base: BaseTimelineElement {
+ id: "element-1".to_string(),
+ name: "Clip".to_string(),
+ duration: seconds(5.0),
+ start_time: MediaTime::ZERO,
+ trim_start: MediaTime::ZERO,
+ trim_end: MediaTime::ZERO,
+ source_duration: Some(seconds(10.0)),
+ animations: None,
+ params: Default::default(),
+ },
+ media_id: "media-1".to_string(),
+ is_source_audio_enabled: None,
+ hidden: None,
+ retime: None,
+ effects: None,
+ masks: None,
+ });
+ let scene = Scene {
+ id: scene_id.clone(),
+ name: "Main scene".to_string(),
+ is_main: true,
+ tracks: SceneTracks {
+ overlay: vec![],
+ main: Track::Video {
+ id: "track-1".to_string(),
+ name: "Main".to_string(),
+ elements: vec![element],
+ muted: false,
+ hidden: false,
+ },
+ audio: vec![],
+ },
+ bookmarks: vec![],
+ created_at: date(1),
+ updated_at: date(1),
+ };
+ Project {
+ metadata: ProjectMetadata {
+ id: id.to_string(),
+ name: "Sample Project".to_string(),
+ thumbnail: None,
+ duration: seconds(5.0),
+ created_at: date(1),
+ updated_at: date(2),
+ },
+ scenes: vec![scene],
+ current_scene_id: scene_id,
+ settings: ProjectSettings {
+ fps: FrameRate::FPS_30,
+ canvas_size: CanvasSize {
+ width: 1920,
+ height: 1080,
+ },
+ canvas_size_mode: None,
+ last_custom_canvas_size: None,
+ original_canvas_size: None,
+ background: Background::Color {
+ color: "#000000".to_string(),
+ },
+ },
+ version: 14,
+ timeline_view_state: None,
+ }
+}
+
+fn sample_media(id: &str, name: &str) -> ArchivedMediaMetadata {
+ ArchivedMediaMetadata {
+ id: id.to_string(),
+ name: name.to_string(),
+ media_type: "video".to_string(),
+ file_type: "video/mp4".to_string(),
+ last_modified: 1_700_000_000_000.0,
+ width: Some(1920.0),
+ height: Some(1080.0),
+ duration: Some(5.0),
+ ephemeral: None,
+ thumbnail_url: None,
+ }
+}
+
+fn store() -> (TempDir, ProjectStore) {
+ let dir = TempDir::new().unwrap();
+ let store = ProjectStore::new(dir.path().join("projects"));
+ (dir, store)
+}
+
+#[test]
+fn project_round_trip() {
+ let (_dir, store) = store();
+ let project = sample_project("project-1");
+
+ store.create_project(&project).unwrap();
+ assert!(store.create_project(&project).is_err());
+
+ let mut updated = project.clone();
+ updated.metadata.name = "Renamed Project".to_string();
+ store.save_project(&updated).unwrap();
+
+ let listed = store.list_projects().unwrap();
+ assert_eq!(listed, vec![updated.metadata.clone()]);
+
+ let loaded = store.load_project("project-1").unwrap();
+ assert_eq!(loaded, updated);
+
+ let raw = std::fs::read_to_string(
+ store.project_dir("project-1").join("project.json"),
+ )
+ .unwrap();
+ assert!(raw.contains("\"currentSceneId\""));
+ assert!(raw.contains("\"createdAt\""));
+}
+
+#[test]
+fn list_projects_sorts_by_updated_descending() {
+ let (_dir, store) = store();
+ store.create_project(&sample_project("project-old")).unwrap();
+ let mut newer = sample_project("project-new");
+ newer.metadata.updated_at = date(20);
+ store.create_project(&newer).unwrap();
+
+ let listed = store.list_projects().unwrap();
+ let ids: Vec<&str> = listed.iter().map(|meta| meta.id.as_str()).collect();
+ assert_eq!(ids, vec!["project-new", "project-old"]);
+}
+
+#[test]
+fn media_round_trip() {
+ let (_dir, store) = store();
+ let project = sample_project("project-1");
+ store.create_project(&project).unwrap();
+
+ let metadata = sample_media("media-1", "my clip.mp4");
+ let bytes = b"fake video bytes";
+ store.add_media("project-1", &metadata, bytes).unwrap();
+
+ let listed = store.list_media("project-1").unwrap();
+ assert_eq!(listed, vec![metadata.clone()]);
+
+ let path = store.media_path("project-1", "media-1").unwrap();
+ assert_eq!(
+ path.file_name().unwrap().to_str().unwrap(),
+ "media-1-my_clip.mp4"
+ );
+ assert_eq!(std::fs::read(&path).unwrap(), bytes);
+
+ let manifest = std::fs::read_to_string(
+ store
+ .project_dir("project-1")
+ .join("media")
+ .join("manifest.json"),
+ )
+ .unwrap();
+ let parsed: Vec<ArchivedMediaMetadata> = serde_json::from_str(&manifest).unwrap();
+ assert_eq!(parsed, vec![metadata.clone()]);
+ assert!(!parsed[0].media_type.is_empty());
+
+ store.delete_media("project-1", "media-1").unwrap();
+ assert!(store.list_media("project-1").unwrap().is_empty());
+ assert!(store.media_path("project-1", "media-1").is_err());
+ assert!(!path.exists());
+}
+
+#[test]
+fn add_media_replaces_existing_id() {
+ let (_dir, store) = store();
+ let project = sample_project("project-1");
+ store.create_project(&project).unwrap();
+
+ let first = sample_media("media-1", "first.mp4");
+ store.add_media("project-1", &first, b"first").unwrap();
+ let second = sample_media("media-1", "second.mp4");
+ store.add_media("project-1", &second, b"second").unwrap();
+
+ let listed = store.list_media("project-1").unwrap();
+ assert_eq!(listed, vec![second]);
+ let path = store.media_path("project-1", "media-1").unwrap();
+ assert_eq!(std::fs::read(&path).unwrap(), b"second");
+ let media_dir = store.project_dir("project-1").join("media");
+ let files: Vec<_> = std::fs::read_dir(&media_dir)
+ .unwrap()
+ .map(|entry| entry.unwrap().file_name())
+ .collect();
+ assert_eq!(files.len(), 2);
+}
+
+#[test]
+fn export_import_round_trip() {
+ let (_dir_a, source) = store();
+ let project = sample_project("project-1");
+ source.create_project(&project).unwrap();
+ let metadata = sample_media("media-1", "my clip.mp4");
+ let bytes = b"fake video bytes";
+ source.add_media("project-1", &metadata, bytes).unwrap();
+
+ let archive = source.export_ocp("project-1").unwrap();
+
+ let (_dir_b, target) = store();
+ let imported_id = target.import_ocp(&archive).unwrap();
+ assert_eq!(imported_id, "project-1");
+
+ let loaded = target.load_project(&imported_id).unwrap();
+ assert_eq!(loaded, project);
+
+ let listed = target.list_media(&imported_id).unwrap();
+ assert_eq!(listed, vec![metadata]);
+ let path = target.media_path(&imported_id, "media-1").unwrap();
+ assert_eq!(std::fs::read(&path).unwrap(), bytes);
+}
+
+#[test]
+fn delete_project_removes_directory() {
+ let (_dir, store) = store();
+ let project = sample_project("project-1");
+ store.create_project(&project).unwrap();
+ store
+ .add_media("project-1", &sample_media("media-1", "clip.mp4"), b"data")
+ .unwrap();
+
+ let project_dir = store.project_dir("project-1");
+ assert!(project_dir.is_dir());
+
+ store.delete_project("project-1").unwrap();
+ assert!(!project_dir.exists());
+ assert!(store.load_project("project-1").is_err());
+ assert!(store.list_projects().unwrap().is_empty());
+ assert!(store.delete_project("project-1").is_err());
+}
+
+#[test]
+fn import_id_collision_gets_new_id() {
+ let (_dir_a, source) = store();
+ let project = sample_project("project-1");
+ source.create_project(&project).unwrap();
+ let archive = source.export_ocp("project-1").unwrap();
+
+ let (_dir_b, target) = store();
+ let first = target.import_ocp(&archive).unwrap();
+ let second = target.import_ocp(&archive).unwrap();
+
+ assert_eq!(first, "project-1");
+ assert_ne!(first, second);
+ assert_eq!(second, "project-1-1");
+
+ let first_project = target.load_project(&first).unwrap();
+ let second_project = target.load_project(&second).unwrap();
+ assert_eq!(first_project.metadata.id, first);
+ assert_eq!(second_project.metadata.id, second);
+ assert_eq!(second_project.scenes, first_project.scenes);
+
+ let listed = target.list_projects().unwrap();
+ assert_eq!(listed.len(), 2);
+}
diff --git a/rust/crates/text/Cargo.toml b/rust/crates/text/Cargo.toml
new file mode 100644
index 00000000..ae4abe1e
--- /dev/null
+++ b/rust/crates/text/Cargo.toml
@@ -0,0 +1,9 @@
+[package]
+name = "text"
+version = "0.1.0"
+edition = "2024"
+
+[dependencies]
+scene = { version = "0.1.0", path = "../scene" }
+cosmic-text = "0.15"
+thiserror = "2"
diff --git a/rust/crates/text/src/lib.rs b/rust/crates/text/src/lib.rs
new file mode 100644
index 00000000..8b137891
--- /dev/null
+++ b/rust/crates/text/src/lib.rs
@@ -0,0 +1 @@
+
diff --git a/rust/crates/timeline/Cargo.toml b/rust/crates/timeline/Cargo.toml
new file mode 100644
index 00000000..30df07bd
--- /dev/null
+++ b/rust/crates/timeline/Cargo.toml
@@ -0,0 +1,12 @@
+[package]
+name = "timeline"
+version = "0.1.0"
+edition = "2024"
+
+[dependencies]
+animation = { version = "0.1.0", path = "../animation" }
+chrono = { version = "0.4", features = ["serde"] }
+scene = { version = "0.1.0", path = "../scene" }
+serde = { version = "1.0", features = ["derive"] }
+serde_json = "1.0"
+time = { version = "0.1.0", path = "../time" }
diff --git a/rust/crates/timeline/src/audio_separation.rs b/rust/crates/timeline/src/audio_separation.rs
new file mode 100644
index 00000000..67494380
--- /dev/null
+++ b/rust/crates/timeline/src/audio_separation.rs
@@ -0,0 +1,97 @@
+use scene::{Element, ElementAnimations, ParamValue, ParamValues, UploadAudioElement, VideoElement};
+
+use crate::defaults::DEFAULT_VOLUME;
+
+#[derive(Clone, Copy, Debug, PartialEq)]
+pub struct MediaAudioState {
+ pub has_audio: Option<bool>,
+}
+
+pub fn is_source_audio_enabled(element: &VideoElement) -> bool {
+ element.is_source_audio_enabled != Some(false)
+}
+
+pub fn is_source_audio_separated(element: &VideoElement) -> bool {
+ !is_source_audio_enabled(element)
+}
+
+pub fn can_extract_source_audio(element: &Element, media_asset: Option<&MediaAudioState>) -> bool {
+ let Element::Video(video) = element else {
+ return false;
+ };
+ is_source_audio_enabled(video) && media_asset.is_some_and(|media| media.has_audio != Some(false))
+}
+
+pub fn can_recover_source_audio(element: &Element) -> bool {
+ let Element::Video(video) = element else {
+ return false;
+ };
+ is_source_audio_separated(video)
+}
+
+pub fn can_toggle_source_audio(element: &Element, media_asset: Option<&MediaAudioState>) -> bool {
+ can_recover_source_audio(element) || can_extract_source_audio(element, media_asset)
+}
+
+pub fn does_element_have_enabled_audio(
+ element: &Element,
+ media_asset: Option<&MediaAudioState>,
+) -> bool {
+ match element {
+ Element::Audio(_) => true,
+ Element::Video(video) => {
+ media_asset.is_some_and(|media| media.has_audio != Some(false))
+ && is_source_audio_enabled(video)
+ }
+ _ => false,
+ }
+}
+
+pub fn build_separated_audio_element(
+ source_element: &VideoElement,
+ id: impl Into<String>,
+) -> UploadAudioElement {
+ let source_base = &source_element.base;
+
+ let mut params = ParamValues::new();
+ let volume = match source_base.params.get("volume") {
+ Some(ParamValue::Number(volume)) => *volume,
+ _ => DEFAULT_VOLUME,
+ };
+ params.insert("volume".to_string(), ParamValue::Number(volume));
+ params.insert(
+ "muted".to_string(),
+ ParamValue::Bool(source_base.params.get("muted") == Some(&ParamValue::Bool(true))),
+ );
+
+ UploadAudioElement {
+ base: scene::BaseTimelineElement {
+ id: id.into(),
+ name: source_base.name.clone(),
+ duration: source_base.duration,
+ start_time: source_base.start_time,
+ trim_start: source_base.trim_start,
+ trim_end: source_base.trim_end,
+ source_duration: source_base.source_duration,
+ animations: clone_volume_animations(source_base.animations.as_ref()),
+ params,
+ },
+ retime: source_element.retime,
+ media_id: source_element.media_id.clone(),
+ }
+}
+
+pub fn get_source_audio_action_label(element: &VideoElement) -> &'static str {
+ if is_source_audio_separated(element) {
+ "Recover audio"
+ } else {
+ "Extract audio"
+ }
+}
+
+fn clone_volume_animations(animations: Option<&ElementAnimations>) -> Option<ElementAnimations> {
+ let volume_data = animations?.get("volume")?;
+ let mut volume_only = ElementAnimations::new();
+ volume_only.insert("volume".to_string(), volume_data.clone());
+ animation::clone_animations(Some(&volume_only), true)
+}
diff --git a/rust/crates/timeline/src/creation.rs b/rust/crates/timeline/src/creation.rs
new file mode 100644
index 00000000..a399d3d7
--- /dev/null
+++ b/rust/crates/timeline/src/creation.rs
@@ -0,0 +1,11 @@
+use time::{MediaTime, TICKS_PER_SECOND};
+
+pub const DEFAULT_NEW_ELEMENT_DURATION: MediaTime =
+ MediaTime::from_ticks(5 * TICKS_PER_SECOND);
+
+pub fn to_element_duration_ticks(seconds: Option<f64>) -> Option<MediaTime> {
+ match seconds {
+ None => Some(DEFAULT_NEW_ELEMENT_DURATION),
+ Some(seconds) => MediaTime::from_seconds_f64(seconds),
+ }
+}
diff --git a/rust/crates/timeline/src/defaults.rs b/rust/crates/timeline/src/defaults.rs
new file mode 100644
index 00000000..504bab92
--- /dev/null
+++ b/rust/crates/timeline/src/defaults.rs
@@ -0,0 +1,130 @@
+use scene::{ParamValue, ParamValues, TextElement};
+use time::MediaTime;
+
+use crate::creation::DEFAULT_NEW_ELEMENT_DURATION;
+
+pub const DEFAULT_OPACITY: f64 = 1.0;
+pub const DEFAULT_BLEND_MODE: &str = "normal";
+pub const DEFAULT_VOLUME: f64 = 0.0;
+pub const DEFAULT_TRANSFORM_SCALE: f64 = 1.0;
+pub const DEFAULT_TRANSFORM_POSITION: f64 = 0.0;
+pub const DEFAULT_TRANSFORM_ROTATE: f64 = 0.0;
+
+pub const DEFAULT_TEXT_LETTER_SPACING: f64 = 0.0;
+pub const DEFAULT_TEXT_LINE_HEIGHT: f64 = 1.2;
+
+#[derive(Clone, Debug, PartialEq)]
+pub struct DefaultTextBackground {
+ pub enabled: bool,
+ pub color: String,
+ pub corner_radius: f64,
+ pub padding_x: f64,
+ pub padding_y: f64,
+ pub offset_x: f64,
+ pub offset_y: f64,
+}
+
+pub fn default_text_background() -> DefaultTextBackground {
+ DefaultTextBackground {
+ enabled: false,
+ color: "#000000".to_string(),
+ corner_radius: 0.0,
+ padding_x: 30.0,
+ padding_y: 42.0,
+ offset_x: 0.0,
+ offset_y: 0.0,
+ }
+}
+
+pub fn default_text_params() -> ParamValues {
+ let background = default_text_background();
+ ParamValues::from([
+ ("content".to_string(), ParamValue::String("Default text".to_string())),
+ ("fontSize".to_string(), ParamValue::Number(15.0)),
+ ("fontFamily".to_string(), ParamValue::String("Arial".to_string())),
+ ("color".to_string(), ParamValue::String("#ffffff".to_string())),
+ ("textAlign".to_string(), ParamValue::String("center".to_string())),
+ ("fontWeight".to_string(), ParamValue::String("normal".to_string())),
+ ("fontStyle".to_string(), ParamValue::String("normal".to_string())),
+ ("textDecoration".to_string(), ParamValue::String("none".to_string())),
+ (
+ "letterSpacing".to_string(),
+ ParamValue::Number(DEFAULT_TEXT_LETTER_SPACING),
+ ),
+ (
+ "lineHeight".to_string(),
+ ParamValue::Number(DEFAULT_TEXT_LINE_HEIGHT),
+ ),
+ (
+ "background.enabled".to_string(),
+ ParamValue::Bool(background.enabled),
+ ),
+ (
+ "background.color".to_string(),
+ ParamValue::String(background.color),
+ ),
+ (
+ "background.cornerRadius".to_string(),
+ ParamValue::Number(background.corner_radius),
+ ),
+ (
+ "background.paddingX".to_string(),
+ ParamValue::Number(background.padding_x),
+ ),
+ (
+ "background.paddingY".to_string(),
+ ParamValue::Number(background.padding_y),
+ ),
+ (
+ "background.offsetX".to_string(),
+ ParamValue::Number(background.offset_x),
+ ),
+ (
+ "background.offsetY".to_string(),
+ ParamValue::Number(background.offset_y),
+ ),
+ (
+ "transform.positionX".to_string(),
+ ParamValue::Number(DEFAULT_TRANSFORM_POSITION),
+ ),
+ (
+ "transform.positionY".to_string(),
+ ParamValue::Number(DEFAULT_TRANSFORM_POSITION),
+ ),
+ (
+ "transform.scaleX".to_string(),
+ ParamValue::Number(DEFAULT_TRANSFORM_SCALE),
+ ),
+ (
+ "transform.scaleY".to_string(),
+ ParamValue::Number(DEFAULT_TRANSFORM_SCALE),
+ ),
+ (
+ "transform.rotate".to_string(),
+ ParamValue::Number(DEFAULT_TRANSFORM_ROTATE),
+ ),
+ ("opacity".to_string(), ParamValue::Number(DEFAULT_OPACITY)),
+ (
+ "blendMode".to_string(),
+ ParamValue::String(DEFAULT_BLEND_MODE.to_string()),
+ ),
+ ])
+}
+
+pub fn default_text_element(id: impl Into<String>) -> TextElement {
+ TextElement {
+ base: scene::BaseTimelineElement {
+ id: id.into(),
+ name: "Text".to_string(),
+ duration: DEFAULT_NEW_ELEMENT_DURATION,
+ start_time: MediaTime::ZERO,
+ trim_start: MediaTime::ZERO,
+ trim_end: MediaTime::ZERO,
+ source_duration: None,
+ animations: None,
+ params: default_text_params(),
+ },
+ hidden: None,
+ effects: None,
+ }
+}
diff --git a/rust/crates/timeline/src/drag_utils.rs b/rust/crates/timeline/src/drag_utils.rs
new file mode 100644
index 00000000..6dcc6c6f
--- /dev/null
+++ b/rust/crates/timeline/src/drag_utils.rs
@@ -0,0 +1,14 @@
+use time::{MediaTime, TICKS_PER_SECOND};
+
+use crate::snap::BASE_TIMELINE_PIXELS_PER_SECOND;
+
+pub fn get_mouse_time_from_client_x(
+ client_x: f64,
+ container_left: f64,
+ zoom_level: f64,
+ scroll_left: f64,
+) -> MediaTime {
+ let mouse_x = client_x - container_left + scroll_left;
+ let seconds = (mouse_x / (BASE_TIMELINE_PIXELS_PER_SECOND * zoom_level)).max(0.0);
+ MediaTime::from_ticks((seconds * TICKS_PER_SECOND as f64).round() as i64)
+}
diff --git a/rust/crates/timeline/src/lib.rs b/rust/crates/timeline/src/lib.rs
new file mode 100644
index 00000000..e724b5bc
--- /dev/null
+++ b/rust/crates/timeline/src/lib.rs
@@ -0,0 +1,96 @@
+use scene::{AudioElement, Element, RetimeConfig, SceneTracks};
+use time::MediaTime;
+
+pub mod audio_separation;
+pub mod creation;
+pub mod defaults;
+pub mod drag_utils;
+pub mod placement;
+pub mod resize;
+pub mod retime;
+pub mod ripple;
+pub mod scenes;
+pub mod snap;
+pub mod split;
+pub mod track_capabilities;
+pub mod track_element_update;
+pub mod types;
+pub mod update_pipeline;
+
+pub use audio_separation::*;
+pub use creation::*;
+pub use track_capabilities::*;
+pub use track_element_update::*;
+pub use types::{ElementType, TrackType};
+
+pub fn calculate_total_duration(tracks: &SceneTracks) -> MediaTime {
+ placement::ordered_tracks(tracks)
+ .iter()
+ .flat_map(|track| track.elements().iter())
+ .map(|element| element.base().start_time + element.base().duration)
+ .fold(MediaTime::ZERO, MediaTime::max)
+}
+
+pub fn is_visual_element(element: &Element) -> bool {
+ matches!(
+ element,
+ Element::Video(_)
+ | Element::Image(_)
+ | Element::Text(_)
+ | Element::Sticker(_)
+ | Element::Graphic(_)
+ )
+}
+
+pub fn is_maskable_element(element: &Element) -> bool {
+ matches!(element, Element::Video(_) | Element::Image(_) | Element::Graphic(_))
+}
+
+pub fn is_retimable_element(element: &Element) -> bool {
+ matches!(element, Element::Video(_) | Element::Audio(_))
+}
+
+pub fn can_element_have_audio(element: &Element) -> bool {
+ matches!(element, Element::Audio(_) | Element::Video(_))
+}
+
+pub fn can_element_be_hidden(element: &Element) -> bool {
+ is_visual_element(element)
+}
+
+pub fn has_element_effects(element: &Element) -> bool {
+ let effects = match element {
+ Element::Video(video) => video.effects.as_ref(),
+ Element::Image(image) => image.effects.as_ref(),
+ Element::Text(text) => text.effects.as_ref(),
+ Element::Sticker(sticker) => sticker.effects.as_ref(),
+ Element::Graphic(graphic) => graphic.effects.as_ref(),
+ _ => None,
+ };
+ effects.is_some_and(|effects| !effects.is_empty())
+}
+
+pub fn has_media_id(element: &Element) -> bool {
+ matches!(
+ element,
+ Element::Audio(AudioElement::Upload(_)) | Element::Video(_) | Element::Image(_)
+ )
+}
+
+pub fn element_retime(element: &Element) -> Option<RetimeConfig> {
+ match element {
+ Element::Video(video) => video.retime,
+ Element::Audio(AudioElement::Upload(audio)) => audio.retime,
+ Element::Audio(AudioElement::Library(audio)) => audio.retime,
+ _ => None,
+ }
+}
+
+pub fn set_element_retime(element: &mut Element, retime: Option<RetimeConfig>) {
+ match element {
+ Element::Video(video) => video.retime = retime,
+ Element::Audio(AudioElement::Upload(audio)) => audio.retime = retime,
+ Element::Audio(AudioElement::Library(audio)) => audio.retime = retime,
+ _ => {}
+ }
+}
diff --git a/rust/crates/timeline/src/placement/apply.rs b/rust/crates/timeline/src/placement/apply.rs
new file mode 100644
index 00000000..4e044a68
--- /dev/null
+++ b/rust/crates/timeline/src/placement/apply.rs
@@ -0,0 +1,101 @@
+use scene::{Element, SceneTracks, Track};
+
+use super::track_factory::build_empty_track;
+use super::{PlacementResult, ordered_tracks};
+use crate::track_element_update::update_track_in_scene_tracks;
+use crate::types::TrackType;
+
+pub struct PlacementApplied {
+ pub updated_tracks: SceneTracks,
+ pub target_track_id: String,
+}
+
+pub struct ApplyPlacementParams {
+ pub tracks: SceneTracks,
+ pub placement_result: PlacementResult,
+ pub elements: Vec<Element>,
+ pub new_track_id: String,
+ pub new_track_insert_index_override: Option<usize>,
+}
+
+pub fn apply_placement(params: &ApplyPlacementParams) -> Option<PlacementApplied> {
+ let tracks = &params.tracks;
+ match &params.placement_result {
+ PlacementResult::ExistingTrack { track_index, .. } => {
+ let ordered = ordered_tracks(tracks);
+ let target_track = ordered.get(*track_index)?;
+
+ let updated_tracks =
+ update_track_in_scene_tracks(tracks, target_track.id(), |track| {
+ let mut next = track.clone();
+ next.elements_mut().extend(params.elements.iter().cloned());
+ next
+ });
+
+ Some(PlacementApplied {
+ updated_tracks,
+ target_track_id: target_track.id().to_string(),
+ })
+ }
+ PlacementResult::NewTrack {
+ track_type,
+ insert_index,
+ ..
+ } => {
+ let new_track_id = params.new_track_id.clone();
+ let insert_index = params
+ .new_track_insert_index_override
+ .unwrap_or(*insert_index);
+ let new_track = build_empty_track(new_track_id.clone(), *track_type, None)
+ .with_elements(params.elements.clone());
+
+ let updated_tracks = if *track_type == TrackType::Audio {
+ SceneTracks {
+ audio: insert_into_audio_tracks(tracks, insert_index, new_track),
+ ..tracks.clone()
+ }
+ } else {
+ SceneTracks {
+ overlay: insert_into_overlay_tracks(tracks, insert_index, new_track),
+ ..tracks.clone()
+ }
+ };
+
+ Some(PlacementApplied {
+ updated_tracks,
+ target_track_id: new_track_id,
+ })
+ }
+ }
+}
+
+fn insert_into_overlay_tracks(
+ tracks: &SceneTracks,
+ insert_index: usize,
+ track: Track,
+) -> Vec<Track> {
+ let normalized = insert_index.min(tracks.overlay.len());
+ let mut next = tracks.overlay.clone();
+ next.insert(normalized, track);
+ next
+}
+
+fn insert_into_audio_tracks(tracks: &SceneTracks, insert_index: usize, track: Track) -> Vec<Track> {
+ let audio_index = insert_index
+ .saturating_sub(tracks.overlay.len() + 1)
+ .min(tracks.audio.len());
+ let mut next = tracks.audio.clone();
+ next.insert(audio_index, track);
+ next
+}
+
+trait WithElements {
+ fn with_elements(self, elements: Vec<Element>) -> Self;
+}
+
+impl WithElements for Track {
+ fn with_elements(mut self, elements: Vec<Element>) -> Self {
+ *self.elements_mut() = elements;
+ self
+ }
+}
diff --git a/rust/crates/timeline/src/placement/compatibility.rs b/rust/crates/timeline/src/placement/compatibility.rs
new file mode 100644
index 00000000..9512af49
--- /dev/null
+++ b/rust/crates/timeline/src/placement/compatibility.rs
@@ -0,0 +1,43 @@
+use crate::types::{ElementType, TrackType};
+
+pub fn get_track_type_for_element_type(element_type: ElementType) -> TrackType {
+ match element_type {
+ ElementType::Audio => TrackType::Audio,
+ ElementType::Text => TrackType::Text,
+ ElementType::Sticker | ElementType::Graphic => TrackType::Graphic,
+ ElementType::Effect => TrackType::Effect,
+ ElementType::Video | ElementType::Image => TrackType::Video,
+ }
+}
+
+pub fn can_element_go_on_track(element_type: ElementType, track_type: TrackType) -> bool {
+ get_track_type_for_element_type(element_type) == track_type
+}
+
+pub struct TrackCompatibility {
+ pub is_valid: bool,
+ pub error_message: Option<String>,
+}
+
+pub fn validate_element_track_compatibility(
+ element_type: ElementType,
+ track_type: TrackType,
+) -> TrackCompatibility {
+ let is_valid = can_element_go_on_track(element_type, track_type);
+
+ if !is_valid {
+ return TrackCompatibility {
+ is_valid: false,
+ error_message: Some(format!(
+ "{} elements cannot be placed on {} tracks",
+ element_type.as_str(),
+ track_type.as_str(),
+ )),
+ };
+ }
+
+ TrackCompatibility {
+ is_valid: true,
+ error_message: None,
+ }
+}
diff --git a/rust/crates/timeline/src/placement/insert_index.rs b/rust/crates/timeline/src/placement/insert_index.rs
new file mode 100644
index 00000000..b0feaec6
--- /dev/null
+++ b/rust/crates/timeline/src/placement/insert_index.rs
@@ -0,0 +1,81 @@
+use scene::SceneTracks;
+
+use super::InsertDirection;
+use crate::types::TrackType;
+
+pub fn get_default_insert_index_for_track(tracks: &SceneTracks, track_type: TrackType) -> usize {
+ if track_type == TrackType::Audio {
+ return tracks.overlay.len() + 1 + tracks.audio.len();
+ }
+
+ if track_type == TrackType::Effect {
+ return 0;
+ }
+
+ tracks.overlay.len()
+}
+
+pub fn get_highest_insert_index_for_track(tracks: &SceneTracks, track_type: TrackType) -> usize {
+ if track_type == TrackType::Audio {
+ return tracks.overlay.len() + 1;
+ }
+
+ 0
+}
+
+pub struct PreferredNewTrackPlacement {
+ pub insert_index: usize,
+ pub insert_position: Option<InsertDirection>,
+}
+
+pub fn resolve_preferred_new_track_placement(
+ tracks: &SceneTracks,
+ track_type: TrackType,
+ preferred_index: usize,
+ direction: InsertDirection,
+) -> PreferredNewTrackPlacement {
+ let track_count = tracks.overlay.len() + 1 + tracks.audio.len();
+ if track_count == 0 {
+ return PreferredNewTrackPlacement {
+ insert_index: 0,
+ insert_position: (track_type == TrackType::Audio)
+ .then_some(InsertDirection::Below),
+ };
+ }
+
+ let safe_preferred_index = preferred_index.min(track_count - 1);
+ let main_track_index = tracks.overlay.len();
+
+ if track_type == TrackType::Audio {
+ if safe_preferred_index <= main_track_index {
+ return PreferredNewTrackPlacement {
+ insert_index: main_track_index + 1,
+ insert_position: Some(InsertDirection::Below),
+ };
+ }
+
+ return PreferredNewTrackPlacement {
+ insert_index: match direction {
+ InsertDirection::Above => safe_preferred_index,
+ InsertDirection::Below => safe_preferred_index + 1,
+ },
+ insert_position: Some(direction),
+ };
+ }
+
+ let insert_index = match direction {
+ InsertDirection::Above => safe_preferred_index,
+ InsertDirection::Below => safe_preferred_index + 1,
+ };
+ if insert_index > main_track_index {
+ return PreferredNewTrackPlacement {
+ insert_index: main_track_index,
+ insert_position: Some(InsertDirection::Above),
+ };
+ }
+
+ PreferredNewTrackPlacement {
+ insert_index,
+ insert_position: Some(direction),
+ }
+}
diff --git a/rust/crates/timeline/src/placement/main_track.rs b/rust/crates/timeline/src/placement/main_track.rs
new file mode 100644
index 00000000..01d91d4f
--- /dev/null
+++ b/rust/crates/timeline/src/placement/main_track.rs
@@ -0,0 +1,44 @@
+use scene::{Element, SceneTracks, Track};
+use time::MediaTime;
+
+pub const MAIN_TRACK_NAME: &str = "Main Track";
+
+pub fn get_earliest_main_track_element<'a>(
+ main_track: &'a Track,
+ exclude_element_id: Option<&str>,
+) -> Option<&'a Element> {
+ main_track
+ .elements()
+ .iter()
+ .filter(|element| exclude_element_id != Some(element.base().id.as_str()))
+ .reduce(|earliest, element| {
+ if element.base().start_time < earliest.base().start_time {
+ element
+ } else {
+ earliest
+ }
+ })
+}
+
+pub fn enforce_main_track_start(
+ tracks: &SceneTracks,
+ target_track_id: &str,
+ requested_start_time: MediaTime,
+ exclude_element_id: Option<&str>,
+) -> MediaTime {
+ if tracks.main.id() != target_track_id {
+ return requested_start_time;
+ }
+
+ let Some(earliest_element) =
+ get_earliest_main_track_element(&tracks.main, exclude_element_id)
+ else {
+ return MediaTime::ZERO;
+ };
+
+ if requested_start_time <= earliest_element.base().start_time {
+ return MediaTime::ZERO;
+ }
+
+ requested_start_time
+}
diff --git a/rust/crates/timeline/src/placement/mod.rs b/rust/crates/timeline/src/placement/mod.rs
new file mode 100644
index 00000000..8ad5710e
--- /dev/null
+++ b/rust/crates/timeline/src/placement/mod.rs
@@ -0,0 +1,104 @@
+use scene::{Element, SceneTracks, Track};
+use time::MediaTime;
+
+use crate::types::{ElementType, TrackType};
+
+pub mod apply;
+pub mod compatibility;
+pub mod insert_index;
+pub mod main_track;
+pub mod overlap;
+pub mod resolve;
+pub mod track_factory;
+
+pub use apply::{PlacementApplied, apply_placement};
+pub use compatibility::{
+ can_element_go_on_track, get_track_type_for_element_type, validate_element_track_compatibility,
+};
+pub use insert_index::{
+ get_default_insert_index_for_track, get_highest_insert_index_for_track,
+ resolve_preferred_new_track_placement,
+};
+pub use main_track::{MAIN_TRACK_NAME, enforce_main_track_start, get_earliest_main_track_element};
+pub use overlap::can_place_time_spans_on_track;
+pub use resolve::resolve_track_placement;
+pub use track_factory::build_empty_track;
+
+#[derive(Clone, Debug, PartialEq)]
+pub struct PlacementTimeSpan {
+ pub start_time: MediaTime,
+ pub duration: MediaTime,
+ pub exclude_element_id: Option<String>,
+}
+
+#[derive(Clone, Copy, Debug, PartialEq)]
+pub enum PlacementSubject {
+ ElementType(ElementType),
+ TrackType(TrackType),
+}
+
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+pub enum InsertDirection {
+ Above,
+ Below,
+}
+
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+pub enum VerticalDragDirection {
+ Up,
+ Down,
+}
+
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+pub enum NewTrackPosition {
+ Highest,
+ Default,
+}
+
+#[derive(Clone, Debug, PartialEq)]
+pub enum PlacementStrategy {
+ Explicit {
+ track_id: String,
+ },
+ FirstAvailable,
+ PreferIndex {
+ track_index: usize,
+ hover_direction: InsertDirection,
+ vertical_drag_direction: Option<VerticalDragDirection>,
+ create_new_track_only: bool,
+ },
+ AboveSource {
+ source_track_index: usize,
+ },
+ AlwaysNew {
+ position: NewTrackPosition,
+ },
+}
+
+#[derive(Clone, Debug, PartialEq)]
+pub enum PlacementResult {
+ ExistingTrack {
+ track_id: String,
+ track_index: usize,
+ track_type: TrackType,
+ adjusted_start_time: Option<MediaTime>,
+ },
+ NewTrack {
+ track_type: TrackType,
+ insert_index: usize,
+ insert_position: Option<InsertDirection>,
+ },
+}
+
+pub fn ordered_tracks(tracks: &SceneTracks) -> Vec<&Track> {
+ tracks
+ .overlay
+ .iter()
+ .chain(std::iter::once(&tracks.main))
+ .chain(tracks.audio.iter())
+ .collect()
+}
+
+pub fn track_elements(track: &Track) -> &[Element] {
+ track.elements()
+}
diff --git a/rust/crates/timeline/src/placement/overlap.rs b/rust/crates/timeline/src/placement/overlap.rs
new file mode 100644
index 00000000..98055e9a
--- /dev/null
+++ b/rust/crates/timeline/src/placement/overlap.rs
@@ -0,0 +1,34 @@
+use scene::Element;
+use time::MediaTime;
+
+use super::PlacementTimeSpan;
+
+fn would_element_overlap(
+ elements: &[Element],
+ start_time: MediaTime,
+ end_time: MediaTime,
+ exclude_element_id: Option<&str>,
+) -> bool {
+ elements.iter().any(|element| {
+ if exclude_element_id == Some(element.base().id.as_str()) {
+ return false;
+ }
+
+ let element_end = element.base().start_time + element.base().duration;
+ start_time < element_end && end_time > element.base().start_time
+ })
+}
+
+pub fn can_place_time_spans_on_track(
+ elements: &[Element],
+ time_spans: &[PlacementTimeSpan],
+) -> bool {
+ time_spans.iter().all(|span| {
+ !would_element_overlap(
+ elements,
+ span.start_time,
+ span.start_time + span.duration,
+ span.exclude_element_id.as_deref(),
+ )
+ })
+}
diff --git a/rust/crates/timeline/src/placement/resolve.rs b/rust/crates/timeline/src/placement/resolve.rs
new file mode 100644
index 00000000..e95bc93c
--- /dev/null
+++ b/rust/crates/timeline/src/placement/resolve.rs
@@ -0,0 +1,223 @@
+use scene::{SceneTracks, Track};
+use time::MediaTime;
+
+use super::compatibility::get_track_type_for_element_type;
+use super::insert_index::{
+ get_default_insert_index_for_track, get_highest_insert_index_for_track,
+ resolve_preferred_new_track_placement,
+};
+use super::main_track::enforce_main_track_start;
+use super::overlap::can_place_time_spans_on_track;
+use super::{
+ InsertDirection, NewTrackPosition, PlacementResult, PlacementStrategy, PlacementSubject,
+ PlacementTimeSpan, VerticalDragDirection, ordered_tracks,
+};
+use crate::types::TrackType;
+
+pub struct ResolveTrackPlacementParams {
+ pub tracks: SceneTracks,
+ pub subject: PlacementSubject,
+ pub time_spans: Vec<PlacementTimeSpan>,
+ pub strategy: PlacementStrategy,
+}
+
+fn build_existing_track_result(
+ track: &Track,
+ track_index: usize,
+ tracks: &SceneTracks,
+ time_spans: &[PlacementTimeSpan],
+) -> PlacementResult {
+ let first_span = time_spans.first();
+ let requested_start_time = first_span
+ .map(|span| span.start_time)
+ .unwrap_or(MediaTime::ZERO);
+ let adjusted_start_time = enforce_main_track_start(
+ tracks,
+ track.id(),
+ requested_start_time,
+ first_span.and_then(|span| span.exclude_element_id.as_deref()),
+ );
+
+ PlacementResult::ExistingTrack {
+ track_id: track.id().to_string(),
+ track_index,
+ track_type: TrackType::of_track(track),
+ adjusted_start_time: (adjusted_start_time != requested_start_time)
+ .then_some(adjusted_start_time),
+ }
+}
+
+fn build_new_track_result(
+ track_type: TrackType,
+ insert_index: usize,
+ insert_position: Option<InsertDirection>,
+) -> PlacementResult {
+ PlacementResult::NewTrack {
+ track_type,
+ insert_index,
+ insert_position,
+ }
+}
+
+fn find_first_available_track_index(
+ tracks: &[&Track],
+ track_type: TrackType,
+ time_spans: &[PlacementTimeSpan],
+) -> Option<usize> {
+ tracks.iter().position(|track| {
+ TrackType::of_track(track) == track_type
+ && can_place_time_spans_on_track(track.elements(), time_spans)
+ })
+}
+
+fn resolve_always_new_track(
+ tracks: &SceneTracks,
+ track_type: TrackType,
+ position: NewTrackPosition,
+) -> PlacementResult {
+ let insert_index = match position {
+ NewTrackPosition::Highest => get_highest_insert_index_for_track(tracks, track_type),
+ NewTrackPosition::Default => get_default_insert_index_for_track(tracks, track_type),
+ };
+
+ build_new_track_result(track_type, insert_index, None)
+}
+
+fn get_insert_direction(
+ hover_direction: InsertDirection,
+ vertical_drag_direction: Option<VerticalDragDirection>,
+) -> InsertDirection {
+ match vertical_drag_direction {
+ Some(VerticalDragDirection::Up) => InsertDirection::Above,
+ Some(VerticalDragDirection::Down) => InsertDirection::Below,
+ None => hover_direction,
+ }
+}
+
+pub fn resolve_track_placement(params: &ResolveTrackPlacementParams) -> Option<PlacementResult> {
+ let tracks = &params.tracks;
+ let ordered = ordered_tracks(tracks);
+ let track_type = match params.subject {
+ PlacementSubject::TrackType(track_type) => track_type,
+ PlacementSubject::ElementType(element_type) => {
+ get_track_type_for_element_type(element_type)
+ }
+ };
+ let time_spans = &params.time_spans;
+
+ match &params.strategy {
+ PlacementStrategy::Explicit { track_id } => {
+ let track_index = ordered.iter().position(|track| track.id() == track_id)?;
+ let track = ordered[track_index];
+ if TrackType::of_track(track) != track_type {
+ return None;
+ }
+
+ Some(build_existing_track_result(
+ track,
+ track_index,
+ tracks,
+ time_spans,
+ ))
+ }
+ PlacementStrategy::FirstAvailable => {
+ if let Some(existing_index) =
+ find_first_available_track_index(&ordered, track_type, time_spans)
+ {
+ return Some(build_existing_track_result(
+ ordered[existing_index],
+ existing_index,
+ tracks,
+ time_spans,
+ ));
+ }
+
+ Some(resolve_always_new_track(
+ tracks,
+ track_type,
+ NewTrackPosition::Highest,
+ ))
+ }
+ PlacementStrategy::PreferIndex {
+ track_index,
+ hover_direction,
+ vertical_drag_direction,
+ create_new_track_only,
+ } => {
+ let preferred_track = ordered.get(*track_index);
+ let is_preferred_compatible = preferred_track
+ .is_some_and(|track| TrackType::of_track(track) == track_type);
+ let can_use_existing = !create_new_track_only
+ && is_preferred_compatible
+ && preferred_track.is_some_and(|track| {
+ can_place_time_spans_on_track(track.elements(), time_spans)
+ });
+ if can_use_existing {
+ return Some(build_existing_track_result(
+ preferred_track?,
+ *track_index,
+ tracks,
+ time_spans,
+ ));
+ }
+
+ let direction = get_insert_direction(
+ *hover_direction,
+ if is_preferred_compatible {
+ None
+ } else {
+ *vertical_drag_direction
+ },
+ );
+ let placement = resolve_preferred_new_track_placement(
+ tracks,
+ track_type,
+ *track_index,
+ direction,
+ );
+ Some(build_new_track_result(
+ track_type,
+ placement.insert_index,
+ placement.insert_position,
+ ))
+ }
+ PlacementStrategy::AboveSource { source_track_index } => {
+ let above_track = source_track_index
+ .checked_sub(1)
+ .and_then(|above_index| ordered.get(above_index));
+ if let Some(above_track) = above_track
+ && TrackType::of_track(above_track) == track_type
+ && can_place_time_spans_on_track(above_track.elements(), time_spans)
+ {
+ return Some(build_existing_track_result(
+ above_track,
+ source_track_index - 1,
+ tracks,
+ time_spans,
+ ));
+ }
+
+ if let Some(first_available_index) =
+ find_first_available_track_index(&ordered, track_type, time_spans)
+ {
+ return Some(build_existing_track_result(
+ ordered[first_available_index],
+ first_available_index,
+ tracks,
+ time_spans,
+ ));
+ }
+
+ Some(build_new_track_result(
+ track_type,
+ get_highest_insert_index_for_track(tracks, track_type),
+ None,
+ ))
+ }
+ PlacementStrategy::AlwaysNew { position } => Some(resolve_always_new_track(
+ tracks,
+ track_type,
+ *position,
+ )),
+ }
+}
diff --git a/rust/crates/timeline/src/placement/track_factory.rs b/rust/crates/timeline/src/placement/track_factory.rs
new file mode 100644
index 00000000..0b86ecf2
--- /dev/null
+++ b/rust/crates/timeline/src/placement/track_factory.rs
@@ -0,0 +1,62 @@
+use scene::Track;
+
+use crate::types::TrackType;
+
+pub const DEFAULT_VIDEO_TRACK_NAME: &str = "Video track";
+pub const DEFAULT_TEXT_TRACK_NAME: &str = "Text track";
+pub const DEFAULT_AUDIO_TRACK_NAME: &str = "Audio track";
+pub const DEFAULT_GRAPHIC_TRACK_NAME: &str = "Graphic track";
+pub const DEFAULT_EFFECT_TRACK_NAME: &str = "Effect track";
+
+pub fn default_track_name(track_type: TrackType) -> &'static str {
+ match track_type {
+ TrackType::Video => DEFAULT_VIDEO_TRACK_NAME,
+ TrackType::Text => DEFAULT_TEXT_TRACK_NAME,
+ TrackType::Audio => DEFAULT_AUDIO_TRACK_NAME,
+ TrackType::Graphic => DEFAULT_GRAPHIC_TRACK_NAME,
+ TrackType::Effect => DEFAULT_EFFECT_TRACK_NAME,
+ }
+}
+
+pub fn build_empty_track(
+ id: impl Into<String>,
+ track_type: TrackType,
+ name: Option<String>,
+) -> Track {
+ let id = id.into();
+ let name = name.unwrap_or_else(|| default_track_name(track_type).to_string());
+
+ match track_type {
+ TrackType::Video => Track::Video {
+ id,
+ name,
+ elements: Vec::new(),
+ muted: false,
+ hidden: false,
+ },
+ TrackType::Text => Track::Text {
+ id,
+ name,
+ elements: Vec::new(),
+ hidden: false,
+ },
+ TrackType::Audio => Track::Audio {
+ id,
+ name,
+ elements: Vec::new(),
+ muted: false,
+ },
+ TrackType::Graphic => Track::Graphic {
+ id,
+ name,
+ elements: Vec::new(),
+ hidden: false,
+ },
+ TrackType::Effect => Track::Effect {
+ id,
+ name,
+ elements: Vec::new(),
+ hidden: false,
+ },
+ }
+}
diff --git a/rust/crates/timeline/src/resize.rs b/rust/crates/timeline/src/resize.rs
new file mode 100644
index 00000000..51478a09
--- /dev/null
+++ b/rust/crates/timeline/src/resize.rs
@@ -0,0 +1,401 @@
+use scene::{ElementRef, RetimeConfig, SceneTracks};
+use time::{FrameRate, MediaTime, TICKS_PER_SECOND};
+
+use crate::retime::{
+ get_source_span_at_clip_time, get_timeline_duration_for_source_span, round_media_time,
+};
+
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+pub enum ResizeSide {
+ Left,
+ Right,
+}
+
+#[derive(Clone, Debug, PartialEq)]
+pub struct GroupResizePushTarget {
+ pub track_id: String,
+ pub element_id: String,
+ pub start_time: MediaTime,
+ pub duration: MediaTime,
+ pub trim_start: MediaTime,
+ pub trim_end: MediaTime,
+}
+
+#[derive(Clone, Debug, PartialEq)]
+pub struct GroupResizeMember {
+ pub track_id: String,
+ pub element_id: String,
+ pub start_time: MediaTime,
+ pub duration: MediaTime,
+ pub trim_start: MediaTime,
+ pub trim_end: MediaTime,
+ pub source_duration: Option<MediaTime>,
+ pub retime: Option<RetimeConfig>,
+ pub left_neighbor_bound: Option<MediaTime>,
+ pub right_neighbor_bound: Option<MediaTime>,
+ pub right_push_chain: Option<Vec<GroupResizePushTarget>>,
+}
+
+#[derive(Clone, Debug, PartialEq)]
+pub struct GroupResizePatch {
+ pub trim_start: MediaTime,
+ pub trim_end: MediaTime,
+ pub start_time: MediaTime,
+ pub duration: MediaTime,
+}
+
+#[derive(Clone, Debug, PartialEq)]
+pub struct GroupResizeUpdate {
+ pub track_id: String,
+ pub element_id: String,
+ pub patch: GroupResizePatch,
+}
+
+#[derive(Clone, Debug, PartialEq)]
+pub struct GroupResizeResult {
+ pub delta_time: MediaTime,
+ pub updates: Vec<GroupResizeUpdate>,
+}
+
+pub fn compute_group_resize(
+ members: &[GroupResizeMember],
+ side: ResizeSide,
+ delta_time: MediaTime,
+ fps: FrameRate,
+) -> GroupResizeResult {
+ if members.is_empty() {
+ return GroupResizeResult {
+ delta_time: MediaTime::ZERO,
+ updates: Vec::new(),
+ };
+ }
+
+ let min_duration = MediaTime::from_ticks(
+ ((TICKS_PER_SECOND as f64 * fps.denominator as f64) / fps.numerator as f64).round() as i64,
+ );
+ let mut minimum_delta_time = get_minimum_allowed_delta_time(&members[0], side, min_duration);
+ let mut maximum_delta_time = get_maximum_allowed_delta_time(&members[0], side, min_duration);
+
+ for member in &members[1..] {
+ minimum_delta_time =
+ minimum_delta_time.max(get_minimum_allowed_delta_time(member, side, min_duration));
+ if let Some(member_maximum) = get_maximum_allowed_delta_time(member, side, min_duration) {
+ maximum_delta_time = Some(match maximum_delta_time {
+ None => member_maximum,
+ Some(current) => current.min(member_maximum),
+ });
+ }
+ }
+
+ let clamped_delta_time = match maximum_delta_time {
+ None => minimum_delta_time.max(delta_time),
+ Some(maximum) => delta_time.clamp(minimum_delta_time, maximum),
+ };
+
+ let snapped_delta_time = clamped_delta_time
+ .round_to_frame(fps)
+ .unwrap_or(clamped_delta_time);
+ let final_delta_time = match maximum_delta_time {
+ None => minimum_delta_time.max(snapped_delta_time),
+ Some(maximum) => snapped_delta_time.clamp(minimum_delta_time, maximum),
+ };
+
+ let mut updates: Vec<GroupResizeUpdate> = members
+ .iter()
+ .map(|member| build_resize_update(member, side, final_delta_time))
+ .collect();
+ updates.extend(build_ripple_push_updates(members, side, final_delta_time));
+
+ GroupResizeResult {
+ delta_time: final_delta_time,
+ updates,
+ }
+}
+
+fn build_ripple_push_updates(
+ members: &[GroupResizeMember],
+ side: ResizeSide,
+ delta_time: MediaTime,
+) -> Vec<GroupResizeUpdate> {
+ if side != ResizeSide::Right || delta_time <= MediaTime::ZERO {
+ return Vec::new();
+ }
+
+ let mut overflow_by_target: Vec<(GroupResizePushTarget, MediaTime)> = Vec::new();
+ for member in members {
+ let (Some(right_neighbor_bound), Some(push_chain)) =
+ (member.right_neighbor_bound, &member.right_push_chain)
+ else {
+ continue;
+ };
+ if member.source_duration.is_some() {
+ continue;
+ }
+
+ let neighbor_ceiling = right_neighbor_bound - (member.start_time + member.duration);
+ let overflow = delta_time - neighbor_ceiling;
+ if overflow <= MediaTime::ZERO {
+ continue;
+ }
+ for target in push_chain {
+ match overflow_by_target
+ .iter_mut()
+ .find(|(existing, _)| existing.element_id == target.element_id)
+ {
+ Some((_, existing_overflow)) => {
+ if overflow > *existing_overflow {
+ *existing_overflow = overflow;
+ }
+ }
+ None => overflow_by_target.push((target.clone(), overflow)),
+ }
+ }
+ }
+
+ overflow_by_target
+ .into_iter()
+ .map(|(target, overflow)| GroupResizeUpdate {
+ track_id: target.track_id,
+ element_id: target.element_id,
+ patch: GroupResizePatch {
+ trim_start: target.trim_start,
+ trim_end: target.trim_end,
+ start_time: target.start_time + overflow,
+ duration: target.duration,
+ },
+ })
+ .collect()
+}
+
+fn build_resize_update(
+ member: &GroupResizeMember,
+ side: ResizeSide,
+ delta_time: MediaTime,
+) -> GroupResizeUpdate {
+ let source_delta = get_source_delta_for_clip_delta(member, delta_time);
+
+ match side {
+ ResizeSide::Left => GroupResizeUpdate {
+ track_id: member.track_id.clone(),
+ element_id: member.element_id.clone(),
+ patch: GroupResizePatch {
+ trim_start: MediaTime::ZERO.max(member.trim_start + source_delta),
+ trim_end: member.trim_end,
+ start_time: member.start_time + delta_time,
+ duration: member.duration - delta_time,
+ },
+ },
+ ResizeSide::Right => GroupResizeUpdate {
+ track_id: member.track_id.clone(),
+ element_id: member.element_id.clone(),
+ patch: GroupResizePatch {
+ trim_start: member.trim_start,
+ trim_end: MediaTime::ZERO.max(member.trim_end - source_delta),
+ start_time: member.start_time,
+ duration: member.duration + delta_time,
+ },
+ },
+ }
+}
+
+fn get_minimum_allowed_delta_time(
+ member: &GroupResizeMember,
+ side: ResizeSide,
+ min_duration: MediaTime,
+) -> MediaTime {
+ if side == ResizeSide::Right {
+ return min_duration - member.duration;
+ }
+
+ let left_neighbor_floor = match member.left_neighbor_bound {
+ Some(bound) => bound - member.start_time,
+ None => MediaTime::ZERO - member.start_time,
+ };
+ if member.source_duration.is_none() {
+ return left_neighbor_floor;
+ }
+
+ let maximum_source_extension = get_duration_for_visible_source_span(
+ member,
+ get_visible_source_span_for_duration(member, member.duration) + member.trim_start,
+ ) - member.duration;
+ left_neighbor_floor.max(MediaTime::ZERO - maximum_source_extension)
+}
+
+fn get_maximum_allowed_delta_time(
+ member: &GroupResizeMember,
+ side: ResizeSide,
+ min_duration: MediaTime,
+) -> Option<MediaTime> {
+ if side == ResizeSide::Left {
+ return Some(member.duration - min_duration);
+ }
+
+ let right_neighbor_ceiling = member
+ .right_neighbor_bound
+ .map(|bound| bound - (member.start_time + member.duration));
+ if member.source_duration.is_none() {
+ return if member.right_push_chain.is_none() {
+ right_neighbor_ceiling
+ } else {
+ None
+ };
+ }
+
+ let maximum_visible_source_span = get_source_duration(member) - member.trim_start;
+ let maximum_duration = get_duration_for_visible_source_span(member, maximum_visible_source_span);
+ let source_duration_ceiling = maximum_duration - member.duration;
+ Some(match right_neighbor_ceiling {
+ None => source_duration_ceiling,
+ Some(ceiling) => ceiling.min(source_duration_ceiling),
+ })
+}
+
+fn get_source_delta_for_clip_delta(member: &GroupResizeMember, clip_delta: MediaTime) -> MediaTime {
+ let Some(retime) = &member.retime else {
+ return clip_delta;
+ };
+
+ let source_delta = if clip_delta >= MediaTime::ZERO {
+ get_source_span_at_clip_time(clip_delta.as_ticks() as f64, Some(retime))
+ } else {
+ -get_source_span_at_clip_time(clip_delta.as_ticks().unsigned_abs() as f64, Some(retime))
+ };
+ round_media_time(source_delta)
+}
+
+fn get_visible_source_span_for_duration(member: &GroupResizeMember, duration: MediaTime) -> MediaTime {
+ let Some(retime) = &member.retime else {
+ return duration;
+ };
+
+ round_media_time(get_source_span_at_clip_time(
+ duration.as_ticks() as f64,
+ Some(retime),
+ ))
+}
+
+fn get_duration_for_visible_source_span(
+ member: &GroupResizeMember,
+ source_span: MediaTime,
+) -> MediaTime {
+ let Some(retime) = &member.retime else {
+ return source_span;
+ };
+
+ round_media_time(get_timeline_duration_for_source_span(
+ source_span.as_ticks() as f64,
+ Some(retime),
+ ))
+}
+
+fn get_source_duration(member: &GroupResizeMember) -> MediaTime {
+ if let Some(source_duration) = member.source_duration {
+ return source_duration;
+ }
+
+ member.trim_start
+ + get_visible_source_span_for_duration(member, member.duration)
+ + member.trim_end
+}
+
+pub fn build_resize_members(
+ tracks: &SceneTracks,
+ selected_elements: &[ElementRef],
+) -> Vec<GroupResizeMember> {
+ let selected_ids: Vec<&str> = selected_elements
+ .iter()
+ .map(|reference| reference.element_id.as_str())
+ .collect();
+
+ let mut members = Vec::new();
+ for reference in selected_elements {
+ let Some(track) = crate::track_element_update::find_track_in_scene_tracks(
+ tracks,
+ &reference.track_id,
+ ) else {
+ continue;
+ };
+ let Some(element) = track
+ .elements()
+ .iter()
+ .find(|element| element.base().id == reference.element_id)
+ else {
+ continue;
+ };
+
+ let base = element.base();
+ let other_elements: Vec<&scene::Element> = track
+ .elements()
+ .iter()
+ .filter(|candidate| !selected_ids.contains(&candidate.base().id.as_str()))
+ .collect();
+
+ let left_neighbor_bound = other_elements
+ .iter()
+ .filter(|candidate| {
+ candidate.base().start_time + candidate.base().duration <= base.start_time
+ })
+ .map(|candidate| candidate.base().start_time + candidate.base().duration)
+ .reduce(MediaTime::max);
+
+ let mut right_elements: Vec<&scene::Element> = other_elements
+ .iter()
+ .filter(|candidate| candidate.base().start_time >= base.start_time + base.duration)
+ .copied()
+ .collect();
+ let right_neighbor_bound = right_elements
+ .iter()
+ .map(|candidate| candidate.base().start_time)
+ .reduce(MediaTime::min);
+
+ let right_push_chain = if base.source_duration.is_none() {
+ right_elements.sort_by_key(|candidate| candidate.base().start_time);
+ Some(
+ right_elements
+ .iter()
+ .map(|candidate| GroupResizePushTarget {
+ track_id: reference.track_id.clone(),
+ element_id: candidate.base().id.clone(),
+ start_time: candidate.base().start_time,
+ duration: candidate.base().duration,
+ trim_start: candidate.base().trim_start,
+ trim_end: candidate.base().trim_end,
+ })
+ .collect(),
+ )
+ } else {
+ None
+ };
+
+ members.push(GroupResizeMember {
+ track_id: reference.track_id.clone(),
+ element_id: reference.element_id.clone(),
+ start_time: base.start_time,
+ duration: base.duration,
+ trim_start: base.trim_start,
+ trim_end: base.trim_end,
+ source_duration: base.source_duration,
+ retime: crate::element_retime(element),
+ left_neighbor_bound,
+ right_neighbor_bound,
+ right_push_chain,
+ });
+ }
+
+ members
+}
+
+pub fn has_resize_changes(members: &[GroupResizeMember], result: &GroupResizeResult) -> bool {
+ result.updates.iter().any(|update| {
+ let member = members
+ .iter()
+ .find(|member| member.element_id == update.element_id);
+ member.is_some_and(|member| {
+ member.trim_start != update.patch.trim_start
+ || member.trim_end != update.patch.trim_end
+ || member.start_time != update.patch.start_time
+ || member.duration != update.patch.duration
+ })
+ })
+}
diff --git a/rust/crates/timeline/src/retime.rs b/rust/crates/timeline/src/retime.rs
new file mode 100644
index 00000000..7215f2ec
--- /dev/null
+++ b/rust/crates/timeline/src/retime.rs
@@ -0,0 +1,71 @@
+use scene::RetimeConfig;
+use time::MediaTime;
+
+pub const DEFAULT_RETIME_RATE: f64 = 1.0;
+pub const MIN_RETIME_RATE: f64 = 0.01;
+pub const MAX_RETIME_RATE: f64 = 5.0;
+
+pub fn clamp_retime_rate(rate: f64) -> f64 {
+ if !rate.is_finite() || rate <= 0.0 {
+ return DEFAULT_RETIME_RATE;
+ }
+
+ rate.clamp(MIN_RETIME_RATE, MAX_RETIME_RATE)
+}
+
+pub fn can_maintain_pitch(rate: f64) -> bool {
+ rate.is_finite() && rate > 0.0
+}
+
+pub fn should_maintain_pitch(rate: f64, maintain_pitch: Option<bool>) -> bool {
+ maintain_pitch == Some(true) && can_maintain_pitch(rate)
+}
+
+fn safe_rate(retime: Option<&RetimeConfig>) -> f64 {
+ clamp_retime_rate(retime.map(|retime| retime.rate).unwrap_or(1.0))
+}
+
+pub fn get_source_time_at_clip_time(clip_time: f64, retime: Option<&RetimeConfig>) -> f64 {
+ clip_time * safe_rate(retime)
+}
+
+pub fn get_clip_time_at_source_time(source_time: f64, retime: Option<&RetimeConfig>) -> f64 {
+ source_time / safe_rate(retime)
+}
+
+pub fn get_effective_rate_at(retime: Option<&RetimeConfig>) -> f64 {
+ safe_rate(retime)
+}
+
+pub fn get_timeline_duration_for_source_span(
+ source_span: f64,
+ retime: Option<&RetimeConfig>,
+) -> f64 {
+ if source_span <= 0.0 {
+ return 0.0;
+ }
+ source_span / safe_rate(retime)
+}
+
+pub fn get_source_span_at_clip_time(clip_time: f64, retime: Option<&RetimeConfig>) -> f64 {
+ get_source_time_at_clip_time(clip_time, retime).max(0.0)
+}
+
+pub fn split_retime_at_clip_time(
+ retime: Option<&RetimeConfig>,
+) -> (Option<RetimeConfig>, Option<RetimeConfig>) {
+ (retime.copied(), retime.copied())
+}
+
+pub fn adjust_retime_for_trim_change(retime: Option<&RetimeConfig>) -> Option<RetimeConfig> {
+ retime.copied()
+}
+
+pub fn round_media_time(time: f64) -> MediaTime {
+ let rounded_magnitude = time.abs().round();
+ if rounded_magnitude == 0.0 {
+ return MediaTime::ZERO;
+ }
+ let magnitude = rounded_magnitude as i64;
+ MediaTime::from_ticks(if time < 0.0 { -magnitude } else { magnitude })
+}
diff --git a/rust/crates/timeline/src/ripple.rs b/rust/crates/timeline/src/ripple.rs
new file mode 100644
index 00000000..0ed88fc4
--- /dev/null
+++ b/rust/crates/timeline/src/ripple.rs
@@ -0,0 +1,265 @@
+use std::collections::{HashMap, HashSet};
+
+use scene::{Element, SceneTracks};
+use time::MediaTime;
+
+#[derive(Clone, Debug, PartialEq)]
+pub struct RippleAdjustment {
+ pub track_id: String,
+ pub after_time: MediaTime,
+ pub shift_amount: MediaTime,
+}
+
+#[derive(Clone, Copy, Debug, PartialEq)]
+struct Interval {
+ start_time: MediaTime,
+ end_time: MediaTime,
+}
+
+#[derive(Clone, Copy, Debug)]
+struct ElementSpan {
+ start_time: MediaTime,
+ end_time: MediaTime,
+}
+
+pub fn compute_ripple_adjustments(
+ before_tracks: &SceneTracks,
+ after_tracks: &SceneTracks,
+) -> Vec<RippleAdjustment> {
+ let before_list = crate::placement::ordered_tracks(before_tracks);
+ let after_list = crate::placement::ordered_tracks(after_tracks);
+ let after_tracks_by_id: HashMap<&str, &scene::Track> = after_list
+ .iter()
+ .map(|track| (track.id(), *track))
+ .collect();
+ let all_after_element_ids: HashSet<&str> = after_list
+ .iter()
+ .flat_map(|track| track.elements().iter().map(|element| element.base().id.as_str()))
+ .collect();
+
+ let mut adjustments = Vec::new();
+ for before_track in before_list {
+ let after_elements = after_tracks_by_id
+ .get(before_track.id())
+ .map(|track| track.elements())
+ .unwrap_or(&[]);
+ adjustments.extend(compute_track_ripple_adjustments(
+ before_track.id(),
+ before_track.elements(),
+ after_elements,
+ &all_after_element_ids,
+ ));
+ }
+ adjustments
+}
+
+fn compute_track_ripple_adjustments(
+ track_id: &str,
+ before_elements: &[Element],
+ after_elements: &[Element],
+ all_after_element_ids: &HashSet<&str>,
+) -> Vec<RippleAdjustment> {
+ let before_spans = build_element_span_map(before_elements);
+ let after_spans = build_element_span_map(after_elements);
+ let (vacated, joined) = collect_track_intervals(&before_spans, &after_spans, all_after_element_ids);
+ let freed = subtract_interval_sets(&vacated, &joined);
+ build_adjustments(track_id, &freed)
+}
+
+fn build_element_span_map(elements: &[Element]) -> HashMap<String, ElementSpan> {
+ elements
+ .iter()
+ .map(|element| {
+ (
+ element.base().id.clone(),
+ ElementSpan {
+ start_time: element.base().start_time,
+ end_time: element.base().start_time + element.base().duration,
+ },
+ )
+ })
+ .collect()
+}
+
+fn collect_track_intervals(
+ before_spans: &HashMap<String, ElementSpan>,
+ after_spans: &HashMap<String, ElementSpan>,
+ all_after_element_ids: &HashSet<&str>,
+) -> (Vec<Interval>, Vec<Interval>) {
+ let mut vacated = Vec::new();
+ let mut joined = Vec::new();
+
+ for (id, before_span) in before_spans {
+ match after_spans.get(id) {
+ None => {
+ if !all_after_element_ids.contains(id.as_str()) {
+ push_interval(&mut vacated, before_span.start_time, before_span.end_time);
+ }
+ }
+ Some(after_span) => {
+ if before_span.end_time > after_span.end_time {
+ push_interval(&mut vacated, after_span.end_time, before_span.end_time);
+ }
+ }
+ }
+ }
+
+ for (id, after_span) in after_spans {
+ if before_spans.contains_key(id) {
+ continue;
+ }
+ push_interval(&mut joined, after_span.start_time, after_span.end_time);
+ }
+
+ (normalize_intervals(vacated), normalize_intervals(joined))
+}
+
+fn build_adjustments(track_id: &str, intervals: &[Interval]) -> Vec<RippleAdjustment> {
+ intervals
+ .iter()
+ .filter_map(|interval| {
+ let shift_amount = interval.end_time - interval.start_time;
+ if shift_amount <= MediaTime::ZERO {
+ return None;
+ }
+ Some(RippleAdjustment {
+ track_id: track_id.to_string(),
+ after_time: interval.end_time,
+ shift_amount,
+ })
+ })
+ .collect()
+}
+
+fn subtract_interval_sets(source: &[Interval], overlapping: &[Interval]) -> Vec<Interval> {
+ let normalized_source = normalize_intervals(source.to_vec());
+ let normalized_overlapping = normalize_intervals(overlapping.to_vec());
+
+ normalized_source
+ .iter()
+ .flat_map(|source_interval| subtract_single_interval(*source_interval, &normalized_overlapping))
+ .collect()
+}
+
+fn normalize_intervals(intervals: Vec<Interval>) -> Vec<Interval> {
+ let mut valid = Vec::new();
+ for interval in intervals {
+ push_interval(&mut valid, interval.start_time, interval.end_time);
+ }
+ valid.sort_by_key(|interval| interval.start_time);
+
+ let mut merged: Vec<Interval> = Vec::new();
+ for interval in valid {
+ match merged.last_mut() {
+ Some(previous) if interval.start_time <= previous.end_time => {
+ previous.end_time = previous.end_time.max(interval.end_time);
+ }
+ _ => merged.push(interval),
+ }
+ }
+ merged
+}
+
+fn subtract_single_interval(source: Interval, overlapping: &[Interval]) -> Vec<Interval> {
+ let mut remaining = vec![source];
+
+ for overlapping_interval in overlapping {
+ let mut next = Vec::new();
+ for interval in remaining {
+ if overlapping_interval.end_time <= interval.start_time
+ || overlapping_interval.start_time >= interval.end_time
+ {
+ next.push(interval);
+ continue;
+ }
+
+ push_interval(&mut next, interval.start_time, overlapping_interval.start_time);
+ push_interval(&mut next, overlapping_interval.end_time, interval.end_time);
+ }
+ remaining = next;
+
+ if remaining.is_empty() {
+ return Vec::new();
+ }
+ }
+
+ remaining
+}
+
+fn push_interval(intervals: &mut Vec<Interval>, start_time: MediaTime, end_time: MediaTime) {
+ if end_time <= start_time {
+ return;
+ }
+ intervals.push(Interval { start_time, end_time });
+}
+
+pub fn ripple_shift_elements(
+ elements: &[Element],
+ after_time: MediaTime,
+ shift_amount: MediaTime,
+) -> Vec<Element> {
+ elements
+ .iter()
+ .map(|element| {
+ if element.base().start_time >= after_time {
+ let mut shifted = element.clone();
+ shifted.base_mut().start_time = element.base().start_time - shift_amount;
+ shifted
+ } else {
+ element.clone()
+ }
+ })
+ .collect()
+}
+
+pub fn apply_ripple_adjustments(
+ tracks: &SceneTracks,
+ adjustments: &[RippleAdjustment],
+) -> SceneTracks {
+ if adjustments.is_empty() {
+ return tracks.clone();
+ }
+
+ let mut adjustments_by_track: HashMap<&str, Vec<&RippleAdjustment>> = HashMap::new();
+ for adjustment in adjustments {
+ adjustments_by_track
+ .entry(adjustment.track_id.as_str())
+ .or_default()
+ .push(adjustment);
+ }
+
+ let apply_to_track = |track: &scene::Track| {
+ let track_adjustments = adjustments_by_track
+ .get(track.id())
+ .map(Vec::as_slice)
+ .unwrap_or(&[]);
+ apply_track_ripple_adjustments(track, track_adjustments)
+ };
+
+ SceneTracks {
+ overlay: tracks.overlay.iter().map(apply_to_track).collect(),
+ main: apply_to_track(&tracks.main),
+ audio: tracks.audio.iter().map(apply_to_track).collect(),
+ }
+}
+
+fn apply_track_ripple_adjustments(
+ track: &scene::Track,
+ adjustments: &[&RippleAdjustment],
+) -> scene::Track {
+ if adjustments.is_empty() {
+ return track.clone();
+ }
+
+ let mut sorted: Vec<&RippleAdjustment> = adjustments.to_vec();
+ sorted.sort_by_key(|adjustment| std::cmp::Reverse(adjustment.after_time));
+
+ let mut elements = track.elements().to_vec();
+ for adjustment in sorted {
+ elements = ripple_shift_elements(&elements, adjustment.after_time, adjustment.shift_amount);
+ }
+
+ let mut next = track.clone();
+ *next.elements_mut() = elements;
+ next
+}
diff --git a/rust/crates/timeline/src/scenes.rs b/rust/crates/timeline/src/scenes.rs
new file mode 100644
index 00000000..8a7a5c4f
--- /dev/null
+++ b/rust/crates/timeline/src/scenes.rs
@@ -0,0 +1,120 @@
+use chrono::{DateTime, Utc};
+use scene::{Scene, SceneTracks};
+use time::MediaTime;
+
+use crate::placement::MAIN_TRACK_NAME;
+use crate::placement::track_factory::build_empty_track;
+use crate::types::TrackType;
+
+pub fn get_main_scene(scenes: &[Scene]) -> Option<&Scene> {
+ scenes.iter().find(|scene| scene.is_main)
+}
+
+pub fn ensure_main_scene(scenes: &[Scene], next_id: &mut dyn FnMut() -> String, now: DateTime<Utc>) -> Vec<Scene> {
+ if scenes.iter().any(|scene| scene.is_main) {
+ return scenes.to_vec();
+ }
+
+ let main_scene = build_default_scene(
+ next_id(),
+ next_id(),
+ "Main scene".to_string(),
+ true,
+ now,
+ );
+ let mut next = Vec::with_capacity(scenes.len() + 1);
+ next.push(main_scene);
+ next.extend(scenes.iter().cloned());
+ next
+}
+
+pub fn build_default_scene(
+ id: impl Into<String>,
+ main_track_id: impl Into<String>,
+ name: String,
+ is_main: bool,
+ now: DateTime<Utc>,
+) -> Scene {
+ Scene {
+ id: id.into(),
+ name,
+ is_main,
+ tracks: SceneTracks {
+ overlay: Vec::new(),
+ main: build_empty_track(
+ main_track_id,
+ TrackType::Video,
+ Some(MAIN_TRACK_NAME.to_string()),
+ ),
+ audio: Vec::new(),
+ },
+ bookmarks: Vec::new(),
+ created_at: now,
+ updated_at: now,
+ }
+}
+
+pub struct SceneDeleteCheck {
+ pub can_delete: bool,
+ pub reason: Option<String>,
+}
+
+pub fn can_delete_scene(scene: &Scene) -> SceneDeleteCheck {
+ if scene.is_main {
+ return SceneDeleteCheck {
+ can_delete: false,
+ reason: Some("Cannot delete main scene".to_string()),
+ };
+ }
+ SceneDeleteCheck {
+ can_delete: true,
+ reason: None,
+ }
+}
+
+pub fn get_fallback_scene_after_delete<'a>(
+ scenes: &'a [Scene],
+ deleted_scene_id: &str,
+ current_scene_id: Option<&str>,
+) -> Option<&'a Scene> {
+ if current_scene_id != Some(deleted_scene_id) {
+ return scenes.iter().find(|scene| Some(scene.id.as_str()) == current_scene_id);
+ }
+ get_main_scene(scenes)
+}
+
+pub fn find_current_scene<'a>(
+ scenes: &'a [Scene],
+ current_scene_id: &str,
+) -> Option<&'a Scene> {
+ scenes
+ .iter()
+ .find(|scene| scene.id == current_scene_id)
+ .or_else(|| get_main_scene(scenes))
+ .or_else(|| scenes.first())
+}
+
+pub fn get_project_duration_from_scenes(scenes: &[Scene]) -> MediaTime {
+ let Some(main_scene) = get_main_scene(scenes).or_else(|| scenes.first()) else {
+ return MediaTime::ZERO;
+ };
+
+ crate::calculate_total_duration(&main_scene.tracks)
+}
+
+pub fn update_scene_in_array(
+ scenes: &[Scene],
+ scene_id: &str,
+ update: impl Fn(&Scene) -> Scene,
+) -> Vec<Scene> {
+ scenes
+ .iter()
+ .map(|scene| {
+ if scene.id == scene_id {
+ update(scene)
+ } else {
+ scene.clone()
+ }
+ })
+ .collect()
+}
diff --git a/rust/crates/timeline/src/snap.rs b/rust/crates/timeline/src/snap.rs
new file mode 100644
index 00000000..1133726f
--- /dev/null
+++ b/rust/crates/timeline/src/snap.rs
@@ -0,0 +1,113 @@
+use std::collections::HashSet;
+
+use scene::SceneTracks;
+use time::MediaTime;
+
+pub const BASE_TIMELINE_PIXELS_PER_SECOND: f64 = 50.0;
+pub const TIMELINE_ZOOM_MIN: f64 = 0.1;
+pub const TIMELINE_ZOOM_MAX: f64 = 100.0;
+pub const DEFAULT_TIMELINE_SNAP_THRESHOLD_PX: f64 = 10.0;
+
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+pub enum SnapPointType {
+ ElementStart,
+ ElementEnd,
+ Playhead,
+ Bookmark,
+ Keyframe,
+}
+
+#[derive(Clone, Debug, PartialEq)]
+pub struct SnapPoint {
+ pub time: MediaTime,
+ pub kind: SnapPointType,
+ pub element_id: Option<String>,
+ pub track_id: Option<String>,
+}
+
+#[derive(Clone, Debug, PartialEq)]
+pub struct SnapResult {
+ pub snapped_time: MediaTime,
+ pub snap_point: Option<SnapPoint>,
+ pub snap_distance: f64,
+}
+
+pub fn resolve_timeline_snap(
+ target_time: MediaTime,
+ snap_points: &[SnapPoint],
+ max_snap_distance: f64,
+) -> SnapResult {
+ let mut closest_snap_point: Option<&SnapPoint> = None;
+ let mut closest_distance = f64::INFINITY;
+
+ for snap_point in snap_points {
+ let distance = (target_time - snap_point.time).as_ticks().unsigned_abs() as f64;
+ if distance <= max_snap_distance && distance < closest_distance {
+ closest_distance = distance;
+ closest_snap_point = Some(snap_point);
+ }
+ }
+
+ SnapResult {
+ snapped_time: closest_snap_point
+ .map(|snap_point| snap_point.time)
+ .unwrap_or(target_time),
+ snap_point: closest_snap_point.cloned(),
+ snap_distance: closest_distance,
+ }
+}
+
+pub fn get_timeline_snap_threshold_in_ticks(
+ zoom_level: f64,
+ snap_threshold_px: Option<f64>,
+) -> f64 {
+ let threshold_px = snap_threshold_px.unwrap_or(DEFAULT_TIMELINE_SNAP_THRESHOLD_PX);
+ let pixels_per_second = BASE_TIMELINE_PIXELS_PER_SECOND * zoom_level;
+ (threshold_px / pixels_per_second) * time::TICKS_PER_SECOND as f64
+}
+
+pub fn build_timeline_snap_points(
+ sources: impl IntoIterator<Item = Vec<SnapPoint>>,
+) -> Vec<SnapPoint> {
+ sources.into_iter().flatten().collect()
+}
+
+pub fn get_element_edge_snap_points(
+ tracks: &SceneTracks,
+ exclude_element_ids: Option<&HashSet<&str>>,
+) -> Vec<SnapPoint> {
+ let mut snap_points = Vec::new();
+
+ for track in crate::placement::ordered_tracks(tracks) {
+ for element in track.elements() {
+ let base = element.base();
+ if exclude_element_ids.is_some_and(|ids| ids.contains(base.id.as_str())) {
+ continue;
+ }
+
+ snap_points.push(SnapPoint {
+ time: base.start_time,
+ kind: SnapPointType::ElementStart,
+ element_id: Some(base.id.clone()),
+ track_id: Some(track.id().to_string()),
+ });
+ snap_points.push(SnapPoint {
+ time: base.start_time + base.duration,
+ kind: SnapPointType::ElementEnd,
+ element_id: Some(base.id.clone()),
+ track_id: Some(track.id().to_string()),
+ });
+ }
+ }
+
+ snap_points
+}
+
+pub fn get_playhead_snap_points(playhead_time: MediaTime) -> Vec<SnapPoint> {
+ vec![SnapPoint {
+ time: playhead_time,
+ kind: SnapPointType::Playhead,
+ element_id: None,
+ track_id: None,
+ }]
+}
diff --git a/rust/crates/timeline/src/split.rs b/rust/crates/timeline/src/split.rs
new file mode 100644
index 00000000..5e7332b1
--- /dev/null
+++ b/rust/crates/timeline/src/split.rs
@@ -0,0 +1,143 @@
+use scene::{Element, ElementRef, SceneTracks};
+use time::MediaTime;
+
+use crate::retime::{get_source_span_at_clip_time, round_media_time};
+
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+pub enum RetainSide {
+ Both,
+ Left,
+ Right,
+}
+
+#[derive(Clone, Debug, PartialEq)]
+pub struct SplitElementsResult {
+ pub tracks: SceneTracks,
+ pub right_side_elements: Vec<ElementRef>,
+}
+
+pub fn split_elements(
+ tracks: &SceneTracks,
+ targets: &[ElementRef],
+ split_time: MediaTime,
+ retain_side: RetainSide,
+) -> SplitElementsResult {
+ let mut right_side_elements = Vec::new();
+
+ let split_track = |track: &scene::Track, right_side_elements: &mut Vec<ElementRef>| {
+ let has_targets = targets.iter().any(|target| target.track_id == track.id());
+ if !has_targets {
+ return track.clone();
+ }
+
+ let elements = track
+ .elements()
+ .iter()
+ .flat_map(|element| {
+ let should_split = targets
+ .iter()
+ .any(|target| target.element_id == element.base().id);
+ if !should_split {
+ return vec![element.clone()];
+ }
+ split_element(track.id(), element, split_time, retain_side, right_side_elements)
+ })
+ .collect();
+
+ let mut next = track.clone();
+ *next.elements_mut() = elements;
+ next
+ };
+
+ let updated = SceneTracks {
+ overlay: tracks
+ .overlay
+ .iter()
+ .map(|track| split_track(track, &mut right_side_elements))
+ .collect(),
+ main: split_track(&tracks.main, &mut right_side_elements),
+ audio: tracks
+ .audio
+ .iter()
+ .map(|track| split_track(track, &mut right_side_elements))
+ .collect(),
+ };
+
+ SplitElementsResult {
+ tracks: updated,
+ right_side_elements,
+ }
+}
+
+fn split_element(
+ track_id: &str,
+ element: &Element,
+ split_time: MediaTime,
+ retain_side: RetainSide,
+ right_side_elements: &mut Vec<ElementRef>,
+) -> Vec<Element> {
+ let base = element.base();
+ let effective_start = base.start_time;
+ let effective_end = base.start_time + base.duration;
+
+ if split_time <= effective_start || split_time >= effective_end {
+ return vec![element.clone()];
+ }
+
+ let relative_time = split_time - base.start_time;
+ let left_visible_duration = relative_time;
+ let right_visible_duration = base.duration - relative_time;
+ let retime = crate::element_retime(element);
+
+ let left_source_span = round_media_time(get_source_span_at_clip_time(
+ left_visible_duration.as_ticks() as f64,
+ retime.as_ref(),
+ ));
+ let total_source_span = round_media_time(get_source_span_at_clip_time(
+ base.duration.as_ticks() as f64,
+ retime.as_ref(),
+ ));
+ let right_source_span = total_source_span - left_source_span;
+
+ let (left_animations, right_animations) = animation::split_animations_at_time_with_options(
+ base.animations.as_ref(),
+ relative_time,
+ true,
+ );
+
+ let left_trim_end = base.trim_end + right_source_span;
+ let right_trim_start = base.trim_start + left_source_span;
+
+ let build_left = || {
+ let mut left = element.clone();
+ let left_base = left.base_mut();
+ left_base.duration = left_visible_duration;
+ left_base.trim_end = left_trim_end;
+ left_base.name = format!("{} (left)", left_base.name);
+ left_base.animations = left_animations;
+ left
+ };
+
+ let build_right = |right_side_elements: &mut Vec<ElementRef>| {
+ let new_id = animation::generate_uuid();
+ right_side_elements.push(ElementRef {
+ track_id: track_id.to_string(),
+ element_id: new_id.clone(),
+ });
+ let mut right = element.clone();
+ let right_base = right.base_mut();
+ right_base.id = new_id;
+ right_base.start_time = split_time;
+ right_base.duration = right_visible_duration;
+ right_base.trim_start = right_trim_start;
+ right_base.name = format!("{} (right)", right_base.name);
+ right_base.animations = right_animations;
+ right
+ };
+
+ match retain_side {
+ RetainSide::Left => vec![build_left()],
+ RetainSide::Right => vec![build_right(right_side_elements)],
+ RetainSide::Both => vec![build_left(), build_right(right_side_elements)],
+ }
+}
diff --git a/rust/crates/timeline/src/track_capabilities.rs b/rust/crates/timeline/src/track_capabilities.rs
new file mode 100644
index 00000000..fe93316f
--- /dev/null
+++ b/rust/crates/timeline/src/track_capabilities.rs
@@ -0,0 +1,9 @@
+use scene::Track;
+
+pub fn can_track_have_audio(track: &Track) -> bool {
+ matches!(track, Track::Audio { .. } | Track::Video { .. })
+}
+
+pub fn can_track_be_hidden(track: &Track) -> bool {
+ !matches!(track, Track::Audio { .. })
+}
diff --git a/rust/crates/timeline/src/track_element_update.rs b/rust/crates/timeline/src/track_element_update.rs
new file mode 100644
index 00000000..98395ab0
--- /dev/null
+++ b/rust/crates/timeline/src/track_element_update.rs
@@ -0,0 +1,97 @@
+use scene::{Element, SceneTracks, Track};
+
+pub fn find_track_in_scene_tracks<'a>(tracks: &'a SceneTracks, track_id: &str) -> Option<&'a Track> {
+ if tracks.main.id() == track_id {
+ return Some(&tracks.main);
+ }
+
+ tracks
+ .overlay
+ .iter()
+ .chain(tracks.audio.iter())
+ .find(|track| track.id() == track_id)
+}
+
+pub fn update_track_in_scene_tracks(
+ tracks: &SceneTracks,
+ track_id: &str,
+ update: impl Fn(&Track) -> Track,
+) -> SceneTracks {
+ if tracks.main.id() == track_id {
+ return SceneTracks {
+ main: update(&tracks.main),
+ ..tracks.clone()
+ };
+ }
+
+ if tracks.overlay.iter().any(|track| track.id() == track_id) {
+ return SceneTracks {
+ overlay: tracks
+ .overlay
+ .iter()
+ .map(|track| {
+ if track.id() == track_id {
+ update(track)
+ } else {
+ track.clone()
+ }
+ })
+ .collect(),
+ ..tracks.clone()
+ };
+ }
+
+ if tracks.audio.iter().any(|track| track.id() == track_id) {
+ return SceneTracks {
+ audio: tracks
+ .audio
+ .iter()
+ .map(|track| {
+ if track.id() == track_id {
+ update(track)
+ } else {
+ track.clone()
+ }
+ })
+ .collect(),
+ ..tracks.clone()
+ };
+ }
+
+ tracks.clone()
+}
+
+fn with_replaced_elements(track: &Track, elements: Vec<Element>) -> Track {
+ let mut next = track.clone();
+ *next.elements_mut() = elements;
+ next
+}
+
+pub fn update_element_in_scene_tracks(
+ tracks: &SceneTracks,
+ track_id: &str,
+ element_id: &str,
+ update: impl Fn(&Element) -> Element,
+ element_predicate: Option<&dyn Fn(&Element) -> bool>,
+) -> SceneTracks {
+ let update_in_track = |track: &Track| {
+ let elements = track
+ .elements()
+ .iter()
+ .map(|element| {
+ if element.base().id != element_id {
+ return element.clone();
+ }
+ if let Some(predicate) = element_predicate
+ && !predicate(element)
+ {
+ return element.clone();
+ }
+ update(element)
+ })
+ .collect();
+ with_replaced_elements(track, elements)
+ };
+
+ update_track_in_scene_tracks(tracks, track_id, update_in_track)
+}
diff --git a/rust/crates/timeline/src/types.rs b/rust/crates/timeline/src/types.rs
new file mode 100644
index 00000000..b142c2ea
--- /dev/null
+++ b/rust/crates/timeline/src/types.rs
@@ -0,0 +1,69 @@
+use scene::{Element, Track};
+
+#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
+pub enum TrackType {
+ Video,
+ Text,
+ Audio,
+ Graphic,
+ Effect,
+}
+
+impl TrackType {
+ pub fn as_str(self) -> &'static str {
+ match self {
+ TrackType::Video => "video",
+ TrackType::Text => "text",
+ TrackType::Audio => "audio",
+ TrackType::Graphic => "graphic",
+ TrackType::Effect => "effect",
+ }
+ }
+
+ pub fn of_track(track: &Track) -> TrackType {
+ match track {
+ Track::Video { .. } => TrackType::Video,
+ Track::Text { .. } => TrackType::Text,
+ Track::Audio { .. } => TrackType::Audio,
+ Track::Graphic { .. } => TrackType::Graphic,
+ Track::Effect { .. } => TrackType::Effect,
+ }
+ }
+}
+
+#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
+pub enum ElementType {
+ Audio,
+ Video,
+ Image,
+ Text,
+ Sticker,
+ Graphic,
+ Effect,
+}
+
+impl ElementType {
+ pub fn as_str(self) -> &'static str {
+ match self {
+ ElementType::Audio => "audio",
+ ElementType::Video => "video",
+ ElementType::Image => "image",
+ ElementType::Text => "text",
+ ElementType::Sticker => "sticker",
+ ElementType::Graphic => "graphic",
+ ElementType::Effect => "effect",
+ }
+ }
+
+ pub fn of_element(element: &Element) -> ElementType {
+ match element {
+ Element::Audio(_) => ElementType::Audio,
+ Element::Video(_) => ElementType::Video,
+ Element::Image(_) => ElementType::Image,
+ Element::Text(_) => ElementType::Text,
+ Element::Sticker(_) => ElementType::Sticker,
+ Element::Graphic(_) => ElementType::Graphic,
+ Element::Effect(_) => ElementType::Effect,
+ }
+ }
+}
diff --git a/rust/crates/timeline/src/update_pipeline.rs b/rust/crates/timeline/src/update_pipeline.rs
new file mode 100644
index 00000000..83b03d85
--- /dev/null
+++ b/rust/crates/timeline/src/update_pipeline.rs
@@ -0,0 +1,158 @@
+use std::collections::HashSet;
+
+use scene::{Element, ElementAnimations, ParamValues, RetimeConfig, SceneTracks};
+use time::MediaTime;
+
+use crate::retime::{
+ clamp_retime_rate, get_source_span_at_clip_time, get_timeline_duration_for_source_span,
+ round_media_time,
+};
+
+#[derive(Clone, Debug, Default)]
+pub struct ElementPatch {
+ pub name: Option<String>,
+ pub duration: Option<MediaTime>,
+ pub start_time: Option<MediaTime>,
+ pub trim_start: Option<MediaTime>,
+ pub trim_end: Option<MediaTime>,
+ pub source_duration: Option<Option<MediaTime>>,
+ pub animations: Option<Option<ElementAnimations>>,
+ pub params: Option<ParamValues>,
+ pub retime: Option<Option<RetimeConfig>>,
+}
+
+pub struct ElementUpdateContext<'a> {
+ pub tracks: &'a SceneTracks,
+ pub track_id: &'a str,
+}
+
+#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
+enum UpdateField {
+ Retime,
+ Duration,
+ StartTime,
+}
+
+pub fn apply_element_update(
+ element: &Element,
+ patch: &ElementPatch,
+ context: &ElementUpdateContext,
+) -> Element {
+ let mut next_element = merge_patch(element, patch);
+ let mut changed_fields: HashSet<UpdateField> = HashSet::new();
+ if patch.retime.is_some() {
+ changed_fields.insert(UpdateField::Retime);
+ }
+ if patch.duration.is_some() {
+ changed_fields.insert(UpdateField::Duration);
+ }
+ if patch.start_time.is_some() {
+ changed_fields.insert(UpdateField::StartTime);
+ }
+
+ if changed_fields.contains(&UpdateField::Retime)
+ && let Some(patch_retime) = &patch.retime
+ && crate::is_retimable_element(&next_element)
+ {
+ let next_retime = patch_retime.map(|retime| RetimeConfig {
+ rate: clamp_retime_rate(retime.rate),
+ maintain_pitch: retime.maintain_pitch,
+ });
+
+ let source_duration = get_source_duration(element);
+ let visible_source_span = (source_duration
+ - next_element.base().trim_start.as_ticks() as f64
+ - next_element.base().trim_end.as_ticks() as f64)
+ .max(0.0);
+ let next_duration = round_media_time(get_timeline_duration_for_source_span(
+ visible_source_span,
+ next_retime.as_ref(),
+ ));
+
+ crate::set_element_retime(&mut next_element, next_retime);
+ next_element.base_mut().duration = next_duration;
+ changed_fields.insert(UpdateField::Duration);
+ }
+
+ if changed_fields.contains(&UpdateField::Duration) {
+ next_element.base_mut().animations = animation::clamp_animations_to_duration(
+ next_element.base().animations.as_ref(),
+ next_element.base().duration,
+ );
+ }
+
+ if changed_fields.contains(&UpdateField::StartTime) {
+ let requested_start_time = next_element.base().start_time.max(MediaTime::ZERO);
+ if context.track_id != context.tracks.main.id() {
+ next_element.base_mut().start_time = requested_start_time;
+ } else {
+ let earliest = context
+ .tracks
+ .main
+ .elements()
+ .iter()
+ .filter(|candidate| candidate.base().id != next_element.base().id)
+ .reduce(|earliest, candidate| {
+ if candidate.base().start_time < earliest.base().start_time {
+ candidate
+ } else {
+ earliest
+ }
+ });
+
+ next_element.base_mut().start_time = match earliest {
+ Some(earliest) if requested_start_time > earliest.base().start_time => {
+ requested_start_time
+ }
+ _ => MediaTime::ZERO,
+ };
+ }
+ }
+
+ next_element
+}
+
+fn merge_patch(element: &Element, patch: &ElementPatch) -> Element {
+ let mut next = element.clone();
+ let base = next.base_mut();
+ if let Some(name) = &patch.name {
+ base.name = name.clone();
+ }
+ if let Some(duration) = patch.duration {
+ base.duration = duration;
+ }
+ if let Some(start_time) = patch.start_time {
+ base.start_time = start_time;
+ }
+ if let Some(trim_start) = patch.trim_start {
+ base.trim_start = trim_start;
+ }
+ if let Some(trim_end) = patch.trim_end {
+ base.trim_end = trim_end;
+ }
+ if let Some(source_duration) = patch.source_duration {
+ base.source_duration = source_duration;
+ }
+ if let Some(animations) = &patch.animations {
+ base.animations = animations.clone();
+ }
+ if let Some(params) = &patch.params {
+ base.params.extend(params.clone());
+ }
+ if let Some(retime) = &patch.retime {
+ crate::set_element_retime(&mut next, *retime);
+ }
+ next
+}
+
+fn get_source_duration(element: &Element) -> f64 {
+ let base = element.base();
+ if let Some(source_duration) = base.source_duration {
+ return source_duration.as_ticks() as f64;
+ }
+
+ let retime = crate::element_retime(element);
+ base.trim_start.as_ticks() as f64
+ + get_source_span_at_clip_time(base.duration.as_ticks() as f64, retime.as_ref())
+ + base.trim_end.as_ticks() as f64
+}
diff --git a/rust/crates/transfer/Cargo.toml b/rust/crates/transfer/Cargo.toml
new file mode 100644
index 00000000..f13cb413
--- /dev/null
+++ b/rust/crates/transfer/Cargo.toml
@@ -0,0 +1,10 @@
+[package]
+name = "transfer"
+version = "0.1.0"
+edition = "2024"
+
+[dependencies]
+serde = { version = "1.0", features = ["derive"] }
+serde_json = "1.0"
+thiserror = "2"
+zip = { version = "2", default-features = false }
diff --git a/rust/crates/transfer/src/container.rs b/rust/crates/transfer/src/container.rs
new file mode 100644
index 00000000..bcdc2494
--- /dev/null
+++ b/rust/crates/transfer/src/container.rs
@@ -0,0 +1,277 @@
+use std::collections::BTreeMap;
+use std::io::{Cursor, Read, Write};
+
+use serde::{Deserialize, Serialize};
+use zip::write::SimpleFileOptions;
+use zip::{ZipArchive, ZipWriter};
+
+pub const PROJECT_ARCHIVE_EXTENSION: &str = ".ocp";
+pub const PROJECT_JSON_ENTRY: &str = "project.json";
+pub const MEDIA_MANIFEST_ENTRY: &str = "media/manifest.json";
+pub const MEDIA_ENTRY_PREFIX: &str = "media/";
+
+const MEDIA_MANIFEST_VERSION: u32 = 1;
+
+#[derive(Debug, thiserror::Error)]
+#[error("{message}")]
+pub struct ProjectArchiveError {
+ message: String,
+}
+
+impl ProjectArchiveError {
+ fn new(message: impl Into<String>) -> Self {
+ Self {
+ message: message.into(),
+ }
+ }
+}
+
+#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
+#[serde(rename_all = "camelCase")]
+pub struct ArchivedMediaMetadata {
+ pub id: String,
+ pub name: String,
+ pub media_type: String,
+ pub file_type: String,
+ pub last_modified: f64,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub width: Option<f64>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub height: Option<f64>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub duration: Option<f64>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub ephemeral: Option<bool>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub thumbnail_url: Option<String>,
+}
+
+#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
+#[serde(rename_all = "camelCase")]
+pub struct ArchivedMediaEntry {
+ #[serde(flatten)]
+ pub metadata: ArchivedMediaMetadata,
+ pub entry: String,
+}
+
+pub struct ArchiveMediaInput {
+ pub metadata: ArchivedMediaMetadata,
+ pub data: Vec<u8>,
+}
+
+pub struct ParsedProjectArchive {
+ pub project: serde_json::Map<String, serde_json::Value>,
+ pub media: Vec<ArchivedMediaEntry>,
+ files: BTreeMap<String, Vec<u8>>,
+}
+
+impl std::fmt::Debug for ParsedProjectArchive {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ f.debug_struct("ParsedProjectArchive")
+ .field("project", &self.project)
+ .field("media", &self.media)
+ .finish_non_exhaustive()
+ }
+}
+
+impl ParsedProjectArchive {
+ pub fn read_media_data(&self, entry: &str) -> Result<&[u8], ProjectArchiveError> {
+ self.files
+ .get(entry)
+ .map(Vec::as_slice)
+ .ok_or_else(|| {
+ ProjectArchiveError::new(format!(
+ "Media file is missing from the archive ({entry})"
+ ))
+ })
+ }
+}
+
+#[derive(Serialize)]
+#[serde(rename_all = "camelCase")]
+struct MediaManifest {
+ version: u32,
+ media: Vec<ArchivedMediaEntry>,
+}
+
+pub fn sanitize_file_name(name: &str) -> String {
+ let mut sanitized = String::with_capacity(name.len());
+ let mut last_was_underscore = false;
+ for ch in name.trim().chars() {
+ let valid = ch.is_ascii_alphanumeric() || matches!(ch, '_' | '.' | '-');
+ if valid {
+ sanitized.push(ch);
+ last_was_underscore = false;
+ } else if !last_was_underscore {
+ sanitized.push('_');
+ last_was_underscore = true;
+ }
+ }
+ let trimmed = sanitized.trim_start_matches('.');
+ if trimmed.is_empty() {
+ "file".to_string()
+ } else {
+ trimmed.to_string()
+ }
+}
+
+pub fn build_media_entry_name(asset_id: &str, name: &str) -> String {
+ format!("{MEDIA_ENTRY_PREFIX}{asset_id}-{}", sanitize_file_name(name))
+}
+
+pub fn build_project_archive(
+ project_json: &str,
+ media: Vec<ArchiveMediaInput>,
+) -> Result<Vec<u8>, ProjectArchiveError> {
+ let manifest: Vec<ArchivedMediaEntry> = media
+ .iter()
+ .map(|input| ArchivedMediaEntry {
+ entry: build_media_entry_name(&input.metadata.id, &input.metadata.name),
+ metadata: input.metadata.clone(),
+ })
+ .collect();
+
+ let manifest_json = serde_json::to_string(&MediaManifest {
+ version: MEDIA_MANIFEST_VERSION,
+ media: manifest.clone(),
+ })
+ .map_err(|error| ProjectArchiveError::new(error.to_string()))?;
+
+ let mut cursor = Cursor::new(Vec::new());
+ {
+ let mut writer = ZipWriter::new(&mut cursor);
+ let options = SimpleFileOptions::default()
+ .compression_method(zip::CompressionMethod::Stored);
+ let entries: Vec<(&str, &[u8])> = [
+ (PROJECT_JSON_ENTRY, project_json.as_bytes()),
+ (MEDIA_MANIFEST_ENTRY, manifest_json.as_bytes()),
+ ]
+ .into_iter()
+ .chain(
+ manifest
+ .iter()
+ .zip(media.iter())
+ .map(|(entry, input)| (entry.entry.as_str(), input.data.as_slice())),
+ )
+ .collect();
+ for (name, data) in entries {
+ writer
+ .start_file(name, options)
+ .map_err(|error| ProjectArchiveError::new(error.to_string()))?;
+ writer
+ .write_all(data)
+ .map_err(|error| ProjectArchiveError::new(error.to_string()))?;
+ }
+ writer
+ .finish()
+ .map_err(|error| ProjectArchiveError::new(error.to_string()))?;
+ }
+ Ok(cursor.into_inner())
+}
+
+pub fn parse_project_archive(
+ archive: &[u8],
+) -> Result<ParsedProjectArchive, ProjectArchiveError> {
+ let mut zip = ZipArchive::new(Cursor::new(archive))
+ .map_err(|_| ProjectArchiveError::new("Not a valid project archive (unreadable zip file)"))?;
+
+ let mut files = BTreeMap::new();
+ for index in 0..zip.len() {
+ let mut file = zip
+ .by_index(index)
+ .map_err(|error| ProjectArchiveError::new(error.to_string()))?;
+ let mut data = Vec::with_capacity(file.size() as usize);
+ file.read_to_end(&mut data)
+ .map_err(|error| ProjectArchiveError::new(error.to_string()))?;
+ files.insert(file.name().to_string(), data);
+ }
+
+ let project_bytes = files
+ .get(PROJECT_JSON_ENTRY)
+ .ok_or_else(|| ProjectArchiveError::new("Not a valid project archive (project.json is missing)"))?;
+ let project: serde_json::Value = serde_json::from_slice(project_bytes).map_err(|_| {
+ ProjectArchiveError::new("Not a valid project archive (project.json is not valid JSON)")
+ })?;
+ let serde_json::Value::Object(project) = project else {
+ return Err(ProjectArchiveError::new(
+ "Not a valid project archive (project.json is not a project)",
+ ));
+ };
+
+ let media = parse_media_manifest(&files)?;
+
+ Ok(ParsedProjectArchive {
+ project,
+ media,
+ files,
+ })
+}
+
+fn parse_media_manifest(
+ files: &BTreeMap<String, Vec<u8>>,
+) -> Result<Vec<ArchivedMediaEntry>, ProjectArchiveError> {
+ let Some(manifest_bytes) = files.get(MEDIA_MANIFEST_ENTRY) else {
+ if files.keys().any(|name| name.starts_with(MEDIA_ENTRY_PREFIX)) {
+ return Err(ProjectArchiveError::new(
+ "Not a valid project archive (media manifest is missing)",
+ ));
+ }
+ return Ok(Vec::new());
+ };
+
+ let parsed: serde_json::Value = serde_json::from_slice(manifest_bytes).map_err(|_| {
+ ProjectArchiveError::new("Not a valid project archive (media manifest is not valid JSON)")
+ })?;
+ let Some(media_values) = parsed.get("media").and_then(serde_json::Value::as_array) else {
+ return Err(ProjectArchiveError::new(
+ "Not a valid project archive (media manifest is malformed)",
+ ));
+ };
+
+ media_values
+ .iter()
+ .map(|value| parse_manifest_entry(value, files))
+ .collect()
+}
+
+fn parse_manifest_entry(
+ value: &serde_json::Value,
+ files: &BTreeMap<String, Vec<u8>>,
+) -> Result<ArchivedMediaEntry, ProjectArchiveError> {
+ let invalid = |reason: &str| {
+ ProjectArchiveError::new(format!(
+ "Not a valid project archive (media manifest entry {reason})"
+ ))
+ };
+
+ let entry: ArchivedMediaEntry =
+ serde_json::from_value(value.clone()).map_err(|_| invalid("is malformed"))?;
+ let metadata = &entry.metadata;
+
+ if metadata.id.is_empty() {
+ return Err(invalid("has no id"));
+ }
+ if !entry.entry.starts_with(MEDIA_ENTRY_PREFIX) || entry.entry.contains("..") {
+ return Err(invalid(&format!("({}) has an invalid file path", metadata.id)));
+ }
+ if !matches!(metadata.media_type.as_str(), "image" | "video" | "audio") {
+ return Err(invalid(&format!("({}) has an invalid media type", metadata.id)));
+ }
+ if metadata.file_type.is_empty() {
+ return Err(invalid(&format!("({}) has no file type", metadata.id)));
+ }
+ if !metadata.last_modified.is_finite() {
+ return Err(invalid(&format!(
+ "({}) has no last-modified timestamp",
+ metadata.id
+ )));
+ }
+ if !files.contains_key(&entry.entry) {
+ return Err(ProjectArchiveError::new(format!(
+ "Not a valid project archive (media file {} is missing)",
+ entry.entry
+ )));
+ }
+
+ Ok(entry)
+}
diff --git a/rust/crates/transfer/src/lib.rs b/rust/crates/transfer/src/lib.rs
new file mode 100644
index 00000000..a541e485
--- /dev/null
+++ b/rust/crates/transfer/src/lib.rs
@@ -0,0 +1,8 @@
+mod container;
+
+pub use container::{
+ ArchivedMediaEntry, ArchivedMediaMetadata, ArchiveMediaInput, MEDIA_ENTRY_PREFIX,
+ MEDIA_MANIFEST_ENTRY, PROJECT_ARCHIVE_EXTENSION, PROJECT_JSON_ENTRY, ParsedProjectArchive,
+ ProjectArchiveError, build_media_entry_name, build_project_archive, parse_project_archive,
+ sanitize_file_name,
+};
diff --git a/rust/crates/transfer/tests/container.rs b/rust/crates/transfer/tests/container.rs
new file mode 100644
index 00000000..f05ef329
--- /dev/null
+++ b/rust/crates/transfer/tests/container.rs
@@ -0,0 +1,95 @@
+use std::io::Write as _;
+
+use transfer::*;
+
+fn sample_media() -> Vec<ArchiveMediaInput> {
+ vec![
+ ArchiveMediaInput {
+ metadata: ArchivedMediaMetadata {
+ id: "asset-1".into(),
+ name: "clip one.mp4".into(),
+ media_type: "video".into(),
+ file_type: "video/mp4".into(),
+ last_modified: 1_753_000_000_000.0,
+ width: Some(1920.0),
+ height: Some(1080.0),
+ duration: Some(12.5),
+ ephemeral: None,
+ thumbnail_url: None,
+ },
+ data: vec![1, 2, 3, 4],
+ },
+ ArchiveMediaInput {
+ metadata: ArchivedMediaMetadata {
+ id: "asset-2".into(),
+ name: ".hidden".into(),
+ media_type: "audio".into(),
+ file_type: "audio/wav".into(),
+ last_modified: 1_753_000_000_001.0,
+ width: None,
+ height: None,
+ duration: None,
+ ephemeral: Some(true),
+ thumbnail_url: None,
+ },
+ data: vec![5, 6, 7],
+ },
+ ]
+}
+
+#[test]
+fn sanitize_matches_web_rules() {
+ assert_eq!(sanitize_file_name("clip one.mp4"), "clip_one.mp4");
+ assert_eq!(sanitize_file_name(".."), "file");
+ assert_eq!(sanitize_file_name("...weird..name.."), "weird..name..");
+ assert_eq!(sanitize_file_name("a b"), "a_b");
+ assert_eq!(sanitize_file_name(" "), "file");
+ assert_eq!(sanitize_file_name("caf\u{00e9}.mov"), "caf_.mov");
+}
+
+#[test]
+fn archive_round_trip() {
+ let project_json = r#"{"version":7,"metadata":{"id":"p1"}}"#;
+ let archive = build_project_archive(project_json, sample_media()).expect("build");
+ let parsed = parse_project_archive(&archive).expect("parse");
+
+ assert_eq!(
+ parsed.project.get("version").and_then(|v| v.as_u64()),
+ Some(7)
+ );
+ assert_eq!(parsed.media.len(), 2);
+ assert_eq!(parsed.media[0].entry, "media/asset-1-clip_one.mp4");
+ assert_eq!(parsed.media[1].entry, "media/asset-2-hidden");
+ assert_eq!(
+ parsed.read_media_data("media/asset-1-clip_one.mp4").unwrap(),
+ &[1, 2, 3, 4]
+ );
+ assert_eq!(parsed.read_media_data("media/asset-2-hidden").unwrap(), &[5, 6, 7]);
+}
+
+#[test]
+fn archive_without_media_is_valid() {
+ let archive = build_project_archive("{}", Vec::new()).expect("build");
+ let parsed = parse_project_archive(&archive).expect("parse");
+ assert!(parsed.media.is_empty());
+}
+
+#[test]
+fn rejects_garbage() {
+ let error = parse_project_archive(b"not a zip").unwrap_err();
+ assert!(error.to_string().contains("unreadable zip file"));
+}
+
+#[test]
+fn rejects_missing_project_json() {
+ let mut cursor = std::io::Cursor::new(Vec::new());
+ {
+ let mut writer = zip::ZipWriter::new(&mut cursor);
+ let options: zip::write::SimpleFileOptions = Default::default();
+ writer.start_file("other.txt", options).unwrap();
+ writer.write_all(b"hi").unwrap();
+ writer.finish().unwrap();
+ }
+ let error = parse_project_archive(&cursor.into_inner()).unwrap_err();
+ assert!(error.to_string().contains("project.json is missing"));
+}
diff --git a/rust/crates/transfer/tests/cross_compat.rs b/rust/crates/transfer/tests/cross_compat.rs
new file mode 100644
index 00000000..02e53ac6
--- /dev/null
+++ b/rust/crates/transfer/tests/cross_compat.rs
@@ -0,0 +1,56 @@
+use transfer::*;
+
+#[test]
+fn parses_web_built_archive() {
+ let archive = std::fs::read(concat!(env!("CARGO_MANIFEST_DIR"), "/tests/fixtures/web-built.ocp"))
+ .expect("web-built fixture");
+ let parsed = parse_project_archive(&archive).expect("parse web archive");
+
+ assert_eq!(
+ parsed
+ .project
+ .get("nested")
+ .and_then(|n| n.get("value"))
+ .and_then(|v| v.as_u64()),
+ Some(42)
+ );
+ assert_eq!(parsed.media.len(), 1);
+ assert_eq!(parsed.media[0].entry, "media/web-asset-web_clip.mov");
+ assert_eq!(parsed.media[0].metadata.media_type, "video");
+ assert_eq!(parsed.media[0].metadata.width, Some(1280.0));
+ assert_eq!(
+ parsed.read_media_data("media/web-asset-web_clip.mov").unwrap(),
+ &[9, 8, 7, 6]
+ );
+}
+
+#[test]
+fn writes_rust_built_archive_for_web_parse() {
+ let archive = build_project_archive(
+ r#"{"version":7,"metadata":{"id":"rust-built"}}"#,
+ vec![ArchiveMediaInput {
+ metadata: ArchivedMediaMetadata {
+ id: "rust-asset".into(),
+ name: "rust audio.mp3".into(),
+ media_type: "audio".into(),
+ file_type: "audio/mpeg".into(),
+ last_modified: 1_753_000_000_000.0,
+ width: None,
+ height: None,
+ duration: Some(1.5),
+ ephemeral: None,
+ thumbnail_url: None,
+ },
+ data: vec![4, 3, 2, 1],
+ }],
+ )
+ .expect("build");
+ let parsed = parse_project_archive(&archive).expect("re-parse");
+ assert_eq!(parsed.media[0].entry, "media/rust-asset-rust_audio.mp3");
+ assert_eq!(
+ parsed
+ .read_media_data("media/rust-asset-rust_audio.mp3")
+ .unwrap(),
+ &[4, 3, 2, 1]
+ );
+}
diff --git a/rust/crates/transfer/tests/fixtures/web-built.ocp b/rust/crates/transfer/tests/fixtures/web-built.ocp
new file mode 100644
index 00000000..473897de
--- /dev/null
+++ b/rust/crates/transfer/tests/fixtures/web-built.ocp
Binary files differ
diff --git a/script/cross-check.ts b/script/cross-check.ts
new file mode 100644
index 00000000..4ea430ba
--- /dev/null
+++ b/script/cross-check.ts
@@ -0,0 +1,49 @@
+import { readFileSync, writeFileSync } from "node:fs";
+import {
+ buildProjectArchive,
+ parseProjectArchive,
+} from "../apps/web/src/project/transfer/container";
+
+const direction = process.argv[2];
+
+if (direction === "build") {
+ const projectJson = JSON.stringify({
+ version: 7,
+ metadata: { id: "web-built" },
+ nested: { value: 42 },
+ });
+ const archive = buildProjectArchive({
+ projectJson,
+ media: [
+ {
+ metadata: {
+ id: "web-asset",
+ name: "web clip.mov",
+ mediaType: "video",
+ fileType: "video/quicktime",
+ lastModified: 1_753_000_000_000,
+ width: 1280,
+ height: 720,
+ duration: 3.25,
+ },
+ data: new Uint8Array([9, 8, 7, 6]),
+ },
+ ],
+ });
+ writeFileSync("/tmp/web-built.ocp", archive);
+ console.log("web-built.ocp written");
+} else if (direction === "parse") {
+ const archive = readFileSync("/tmp/rust-built.ocp");
+ const parsed = parseProjectArchive({ archive });
+ console.log(
+ JSON.stringify({
+ project: parsed.project,
+ media: parsed.media,
+ firstBytes: Array.from(
+ parsed.readMediaData({ entry: parsed.media[0]?.entry ?? "" }),
+ ),
+ }),
+ );
+} else {
+ throw new Error("usage: bun cross-check.ts build|parse");
+}