diff options
| author | cashmere <cashmere@autistici.org> | 2026-07-28 19:41:16 +0200 |
|---|---|---|
| committer | cashmere <cashmere@autistici.org> | 2026-07-28 19:41:16 +0200 |
| commit | f5a82152394fde4d8f740f664ab7737b82861618 (patch) | |
| tree | 5a51b181abead1ce03d2dbfc12b77918d3be0e1b | |
| parent | e837e7504379e100ff3b56282577ec67bb88bec9 (diff) | |
| download | opencut-f5a82152394fde4d8f740f664ab7737b82861618.tar.gz | |
feat: GPUI desktop port foundation
- scene crate: full project/scene/element model, serde round-trip
proven against the web SerializedProject shape
- transfer crate: .ocp zip container, cross-compat with web fflate
archives proven in both directions
- storage crate: filesystem project store replacing IndexedDB,
.ocp import/export
- media crate: ffmpeg-next 8 decoder (frames, audio, thumbnails,
waveform summaries)
- playback crate: wall-clock anchored, frame-rounded playback clock
- gpu crate: native texture readback; compositor renders headless
offscreen on Vulkan (spike test)
- flake devshell: rust toolchain, GPUI native deps, ffmpeg dev,
bindgen clang args; GPUI hello-world builds
- port plan in notes/gpui-port.md
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:-}" @@ -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" @@ -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", ] @@ -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) => ¶m.key, + Self::Boolean(param) => ¶m.key, + Self::Color(param) => ¶m.key, + Self::Select(param) => ¶m.key, + Self::Text(param) => ¶m.key, + Self::Font(param) => ¶m.key, + } + } + + pub fn label(&self) -> &str { + match self { + Self::Number(param) => ¶m.label, + Self::Boolean(param) => ¶m.label, + Self::Color(param) => ¶m.label, + Self::Select(param) => ¶m.label, + Self::Text(param) => ¶m.label, + Self::Font(param) => ¶m.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 Binary files differnew file mode 100644 index 00000000..13c113e5 --- /dev/null +++ b/rust/crates/media/tests/fixtures/sample.mp4 diff --git a/rust/crates/media/tests/fixtures/tone.wav b/rust/crates/media/tests/fixtures/tone.wav Binary files differnew file mode 100644 index 00000000..8d5f7886 --- /dev/null +++ b/rust/crates/media/tests/fixtures/tone.wav 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 = ¶ms.tracks; + match ¶ms.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 = ¶ms.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 = ¶ms.time_spans; + + match ¶ms.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 Binary files differnew file mode 100644 index 00000000..473897de --- /dev/null +++ b/rust/crates/transfer/tests/fixtures/web-built.ocp 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"); +} |
