├── .cargo └── config.toml ├── .github ├── ISSUE_TEMPLATE │ ├── bug_ae.md │ ├── bug_openfx.md │ ├── bug_standalone.md │ └── feature_request.md ├── RELEASE_TEMPLATE.md └── workflows │ └── build-workspace.yml ├── .gitignore ├── .gitmodules ├── Cargo.lock ├── Cargo.toml ├── LICENSE-APACHE-2.0 ├── LICENSE-ISC ├── LICENSE-MIT ├── MAINTAINING.md ├── README.md ├── about.toml ├── assets ├── icon.ico ├── icon.png ├── icon.svg ├── install_scripts │ └── postinstall ├── macos_icon.png └── macos_icon_less_detail.png ├── crates ├── ae-plugin │ ├── Cargo.toml │ ├── README.md │ ├── build.rs │ └── src │ │ ├── handle.rs │ │ ├── lib.rs │ │ └── window_handle.rs ├── gui │ ├── Cargo.toml │ ├── LICENSE │ ├── README.md │ ├── about.json │ ├── build.rs │ ├── icon.rc │ ├── src │ │ ├── app │ │ │ ├── app_state.rs │ │ │ ├── dnd_overlay.rs │ │ │ ├── error.rs │ │ │ ├── executor.rs │ │ │ ├── format_eta.rs │ │ │ ├── layout_helper.rs │ │ │ ├── license_dialog.rs │ │ │ ├── main.rs │ │ │ ├── mod.rs │ │ │ ├── pipeline_info.rs │ │ │ ├── presets.rs │ │ │ ├── render_job.rs │ │ │ ├── render_settings.rs │ │ │ ├── system_fonts.rs │ │ │ ├── third_party_licenses_dialog.rs │ │ │ └── ui_context.rs │ │ ├── bin │ │ │ ├── ntsc-rs-cli.rs │ │ │ ├── ntsc-rs-launcher.rs │ │ │ └── ntsc-rs-standalone.rs │ │ ├── expression_parser.rs │ │ ├── gst_utils │ │ │ ├── clock_format.rs │ │ │ ├── egui_sink.rs │ │ │ ├── gstreamer_error.rs │ │ │ ├── init.rs │ │ │ ├── mod.rs │ │ │ ├── multi_file_path.rs │ │ │ ├── ntsc_pipeline.rs │ │ │ ├── ntscrs_filter.rs │ │ │ ├── process_gst_frame.rs │ │ │ └── video_pad_filter.rs │ │ ├── lib.rs │ │ ├── third_party_licenses.rs │ │ └── widgets │ │ │ ├── mod.rs │ │ │ ├── render_job.rs │ │ │ ├── splitscreen.rs │ │ │ └── timeline.rs │ └── todo.md ├── macros │ ├── Cargo.toml │ └── src │ │ └── lib.rs ├── ntscrs │ ├── Cargo.toml │ ├── benches │ │ ├── balloons.png │ │ └── filter_profile.rs │ └── src │ │ ├── f32x4.rs │ │ ├── filter.rs │ │ ├── lib.rs │ │ ├── ntsc.rs │ │ ├── random.rs │ │ ├── settings │ │ ├── easy.rs │ │ ├── mod.rs │ │ ├── settings.rs │ │ └── standard.rs │ │ ├── shift.rs │ │ ├── thread_pool.rs │ │ └── yiq_fielding.rs └── openfx-plugin │ ├── .gitignore │ ├── Cargo.toml │ ├── README.md │ ├── build.rs │ ├── src │ ├── bindings.rs │ └── lib.rs │ └── wrapper.h ├── docs └── img │ ├── appdemo.png │ ├── logo-darkmode.svg │ └── logo-lightmode.svg ├── rustfmt.toml └── xtask ├── Cargo.toml ├── README.md └── src ├── bin └── xtask.rs ├── build_ofx_plugin.rs ├── lib.rs ├── macos_ae_plugin.rs ├── macos_bundle.rs └── util ├── mod.rs └── targets.rs /.cargo/config.toml: -------------------------------------------------------------------------------- 1 | [alias] 2 | xtask = "run --package xtask --" -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_ae.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report (After Effects/Premiere plugin) 3 | about: Report a bug in the After Effects/Premiere plugin 4 | title: '' 5 | labels: bug 6 | assignees: '' 7 | --- 8 | 9 | 28 | 29 | **Describe the bug** 30 | 31 | 32 | **To Reproduce** 33 | 35 | 36 | **Screenshots/Screen Recordings** 37 | 39 | 40 | **Platform Information** 41 | - 43 | 44 | - 46 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_openfx.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report (OpenFX plugin) 3 | about: Report a bug in the OpenFX plugin 4 | title: '' 5 | labels: bug 6 | assignees: '' 7 | --- 8 | 9 | 28 | 29 | **Describe the bug** 30 | 31 | 32 | **To Reproduce** 33 | 35 | 36 | **Screenshots/Screen Recordings** 37 | 39 | 40 | **Platform Information** 41 | - 43 | 44 | - 46 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_standalone.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report (standalone) 3 | about: Report a bug in the standalone application 4 | title: '' 5 | labels: bug 6 | assignees: '' 7 | --- 8 | 9 | 28 | 29 | **Describe the bug** 30 | 31 | 32 | **To Reproduce** 33 | 35 | 36 | **Screenshots/Screen Recordings** 37 | 39 | 40 | **Platform Information** 41 | 43 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: enhancement 6 | assignees: '' 7 | --- 8 | 9 | 24 | -------------------------------------------------------------------------------- /.github/RELEASE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | # What's new 2 | 3 | 4 | --- 5 | 6 | **For installation instructions and further guidance, see [the Getting Started guide on the ntsc-rs website](https://ntsc.rs/docs/getting-started/).** 7 | -------------------------------------------------------------------------------- /.github/workflows/build-workspace.yml: -------------------------------------------------------------------------------- 1 | name: Build Workspace 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | tags: 8 | - 'v[0-9]+.[0-9]+.[0-9]+' 9 | - 'v[0-9]+.[0-9]+.[0-9]+-*' 10 | workflow_dispatch: 11 | 12 | env: 13 | CARGO_TERM_COLOR: always 14 | 15 | jobs: 16 | build-linux: 17 | runs-on: ubuntu-22.04 18 | 19 | steps: 20 | - name: Checkout 21 | uses: actions/checkout@v4 22 | with: 23 | submodules: recursive 24 | 25 | # https://github.com/actions/runner-images/issues/9546 26 | - name: Fix broken packages 27 | run: sudo apt-get remove libunwind-* 28 | 29 | - name: Install gstreamer 30 | uses: awalsh128/cache-apt-pkgs-action@v1 31 | with: 32 | packages: libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev libatk1.0-dev libgtk-3-dev pkg-config 33 | version: 4.0 34 | 35 | - uses: Swatinem/rust-cache@v2 36 | 37 | - name: Build 38 | run: cargo build --release --workspace 39 | 40 | - name: Build OpenFX plugin 41 | run: cargo xtask build-ofx-plugin --release 42 | 43 | - name: Archive Linux OpenFX plugin 44 | uses: actions/upload-artifact@v4 45 | if: ${{ github.ref_type == 'tag' }} 46 | with: 47 | name: ntsc-rs-linux-openfx 48 | path: crates/openfx-plugin/build/ 49 | 50 | - name: Package Linux binaries 51 | run: | 52 | mkdir ntsc-rs-linux-standalone 53 | cp target/release/ntsc-rs-standalone ntsc-rs-linux-standalone 54 | cp target/release/ntsc-rs-cli ntsc-rs-linux-standalone 55 | 56 | - name: Archive Linux binary 57 | uses: actions/upload-artifact@v4 58 | if: ${{ github.ref_type == 'tag' }} 59 | with: 60 | name: ntsc-rs-linux-standalone 61 | path: ntsc-rs-linux-standalone 62 | 63 | build-windows: 64 | runs-on: windows-2022 65 | 66 | steps: 67 | - name: Checkout 68 | uses: actions/checkout@v4 69 | with: 70 | submodules: recursive 71 | 72 | - name: Set up workspace path 73 | run: echo "FAST_WORKSPACE=D:\workspace" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append 74 | 75 | - name: Copy Git repo to D drive 76 | run: | 77 | Copy-Item -Path "${{ github.workspace }}" -Destination "${{ env.FAST_WORKSPACE }}" -Recurse 78 | 79 | # Put the Chocolatey temp directory in a place where we can use the cache action to save and restore it. 80 | - name: Set Chocolatey temp directory 81 | run: | 82 | choco config set cacheLocation --value "D:\chocolatey_cache" 83 | echo "CHOCO_TEMP=D:\chocolatey_cache" | Out-File -FilePath $Env:GITHUB_ENV -Encoding utf8 -Append 84 | 85 | - name: Get current GStreamer package version 86 | run: echo "GST_PACKAGE_VERSION=$(choco info -y --limit-output gstreamer)" | Out-File -FilePath $Env:GITHUB_ENV -Encoding utf8 -Append 87 | 88 | - name: Restore Chocolatey cache 89 | id: choco-cache-restore 90 | uses: actions/cache/restore@v4 91 | with: 92 | path: ${{ env.CHOCO_TEMP }} 93 | key: choco-${{ github.job }}-${{ env.GST_PACKAGE_VERSION }} 94 | 95 | - name: Install gstreamer 96 | # Some things to note: 97 | # - GStreamer adds some environment variables, which we need to pick up. 98 | # - GSTREAMER_1_0_ROOT_MSVC_X86_64 cannot be hardcoded because it can be installed on different drive letters, 99 | # seemingly chosen at random. 100 | # - We need to export said path to the environment variables so that later steps can use it. 101 | run: | 102 | choco install -y gstreamer gstreamer-devel 103 | Import-Module $env:ChocolateyInstall\helpers\chocolateyProfile.psm1 104 | refreshenv 105 | echo "$($Env:GSTREAMER_1_0_ROOT_MSVC_X86_64)bin" 106 | echo "$($Env:GSTREAMER_1_0_ROOT_MSVC_X86_64)bin" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append 107 | echo "GSTREAMER_1_0_ROOT_MSVC_X86_64=$Env:GSTREAMER_1_0_ROOT_MSVC_X86_64" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append 108 | 109 | - name: Save Chocolatey cache 110 | id: choco-cache-save 111 | uses: actions/cache/save@v4 112 | with: 113 | path: ${{ env.CHOCO_TEMP }} 114 | key: choco-${{ github.job }}-${{ env.GST_PACKAGE_VERSION }} 115 | 116 | - uses: Swatinem/rust-cache@v2 117 | with: 118 | workspaces: ${{ env.FAST_WORKSPACE }} 119 | 120 | - name: Build GUI 121 | run: cargo build --release -p gui 122 | working-directory: ${{ env.FAST_WORKSPACE }} 123 | 124 | - name: Build OpenFX plugin 125 | run: cargo xtask build-ofx-plugin --release 126 | working-directory: ${{ env.FAST_WORKSPACE }} 127 | 128 | - name: Build After Effects plugin 129 | run: | 130 | cargo build --release -p ae-plugin 131 | cp target\release\ae_plugin.dll .\ntsc-rs-ae.aex 132 | working-directory: ${{ env.FAST_WORKSPACE }} 133 | 134 | - name: Package OpenFX plugin 135 | run: cargo xtask build-ofx-plugin --release 136 | working-directory: ${{ env.FAST_WORKSPACE }} 137 | 138 | - name: Archive Windows OpenFX plugin 139 | uses: actions/upload-artifact@v4 140 | if: ${{ github.ref_type == 'tag' }} 141 | with: 142 | name: ntsc-rs-windows-openfx 143 | path: ${{ env.FAST_WORKSPACE }}/crates/openfx-plugin/build/ 144 | 145 | - name: Package Windows binary 146 | if: ${{ github.ref_type == 'tag' }} 147 | # Some things to note: 148 | # - Robocopy has a non-zero exit code even when successful. We therefore clear it to 0 so that Actions doesn't 149 | # fail. 150 | run: | 151 | mkdir ntsc-rs-windows-standalone 152 | cd ntsc-rs-windows-standalone 153 | robocopy $Env:GSTREAMER_1_0_ROOT_MSVC_X86_64 .\ *.dll /s /copy:DT; if ($lastexitcode -lt 8) { $global:LASTEXITCODE = $null } 154 | robocopy $Env:GSTREAMER_1_0_ROOT_MSVC_X86_64\share\licenses .\licenses /s /copy:DT; if ($lastexitcode -lt 8) { $global:LASTEXITCODE = $null } 155 | cp ..\target\release\ntsc-rs-standalone.exe .\bin\ 156 | cp ..\target\release\ntsc-rs-cli.exe .\bin\ 157 | cp ..\target\release\ntsc-rs-launcher.exe .\ 158 | working-directory: ${{ env.FAST_WORKSPACE }} 159 | 160 | - name: Archive Windows binary 161 | uses: actions/upload-artifact@v4 162 | if: ${{ github.ref_type == 'tag' }} 163 | with: 164 | name: ntsc-rs-windows-standalone 165 | path: ${{ env.FAST_WORKSPACE }}/ntsc-rs-windows-standalone 166 | 167 | - name: Archive Windows After Effects plugin 168 | uses: actions/upload-artifact@v4 169 | if: ${{ github.ref_type == 'tag' }} 170 | with: 171 | name: ntsc-rs-windows-afterfx 172 | path: ${{ env.FAST_WORKSPACE }}/ntsc-rs-ae.aex 173 | 174 | build-macos: 175 | runs-on: macos-14 176 | 177 | steps: 178 | - name: Checkout 179 | uses: actions/checkout@v4 180 | with: 181 | submodules: recursive 182 | 183 | - name: Set deployment target 184 | run: echo 'MACOSX_DEPLOYMENT_TARGET=10.12' >> $GITHUB_ENV 185 | 186 | - name: Add x86_64 target 187 | run: | 188 | rustup target add x86_64-apple-darwin 189 | 190 | - name: Install packages 191 | run: | 192 | brew install --cask gstreamer-runtime 193 | brew install --cask gstreamer-development 194 | 195 | - name: Setup GStreamer devel 196 | run: | 197 | echo "/Library/Frameworks/GStreamer.framework/Versions/1.0/bin" >> $GITHUB_PATH 198 | echo 'PKG_CONFIG_PATH="/Library/Frameworks/GStreamer.framework/Versions/1.0/lib/pkgconfig"' >> $GITHUB_ENV 199 | 200 | - uses: Swatinem/rust-cache@v2 201 | 202 | - name: Build standalone app 203 | run: cargo xtask macos-bundle --release --destdir=build/ntsc-rs-standalone 204 | 205 | - name: Build OpenFX plugin 206 | run: cargo xtask build-ofx-plugin --macos-universal --release --destdir=build/ntsc-rs-openfx 207 | 208 | - name: Build After Effects plugin 209 | run: cargo xtask macos-ae-plugin --macos-universal --release --destdir=build/ntsc-rs-afterfx 210 | 211 | - name: Create .pkg installers for bundles 212 | if: ${{ github.ref_type == 'tag' }} 213 | # Using the "latest" compression setting is slower (50 seconds vs 25ish) but results in a smaller file 214 | # (around 120MB vs around 180MB). 215 | run: | 216 | pkgbuild --install-location /Applications --component build/ntsc-rs-standalone/ntsc-rs.app --min-os-version $MACOSX_DEPLOYMENT_TARGET --compression latest build/ntsc-rs-macos-standalone.pkg 217 | pkgbuild --install-location /Library/OFX/Plugins --component build/ntsc-rs-openfx/NtscRs.ofx.bundle --min-os-version $MACOSX_DEPLOYMENT_TARGET --compression latest build/ntsc-rs-macos-openfx.pkg 218 | pkgbuild --install-location "/Library/Application Support/Adobe/Common/Plug-ins/7.0/MediaCore" --component build/ntsc-rs-afterfx/ntsc-rs.plugin --min-os-version $MACOSX_DEPLOYMENT_TARGET --scripts assets/install_scripts --compression latest build/ntsc-rs-macos-afterfx.pkg 219 | 220 | - name: Archive .pkg installers 221 | uses: actions/upload-artifact@v4 222 | if: ${{ github.ref_type == 'tag' }} 223 | with: 224 | name: ntsc-rs-macos 225 | path: build/ntsc-rs-*.pkg 226 | 227 | # TODO: sign bundles 228 | 229 | release: 230 | runs-on: ubuntu-latest 231 | permissions: 232 | contents: write 233 | needs: 234 | - build-windows 235 | - build-macos 236 | - build-linux 237 | if: ${{ github.ref_type == 'tag' }} 238 | 239 | steps: 240 | - name: Checkout 241 | uses: actions/checkout@v4 242 | 243 | - name: Download artifacts 244 | uses: actions/download-artifact@v4 245 | with: 246 | path: ./artifacts 247 | 248 | - name: Zip Windows/Linux artifacts 249 | run: | 250 | shopt -s extglob 251 | for dir in ntsc-rs-@(windows|linux)*/; do zip -r "${dir%/}.zip" "${dir%/}"; done 252 | working-directory: ./artifacts 253 | 254 | - name: Display structure of downloaded files 255 | run: ls -l 256 | working-directory: ./artifacts 257 | 258 | - name: Create release 259 | uses: ncipollo/release-action@v1 260 | with: 261 | artifacts: "./artifacts/ntsc-rs-*.zip,./artifacts/ntsc-rs-macos/ntsc-rs-*.pkg" 262 | draft: true 263 | bodyFile: ./.github/RELEASE_TEMPLATE.md 264 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | .vscode 3 | .DS_Store 4 | /build -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "crates/openfx-plugin/vendor/openfx"] 2 | path = crates/openfx-plugin/vendor/openfx 3 | url = https://github.com/AcademySoftwareFoundation/openfx 4 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [workspace] 2 | members = ["crates/*", "xtask"] 3 | resolver = "2" 4 | 5 | [workspace.lints.clippy] 6 | too_many_arguments = "allow" 7 | identity_op = "allow" 8 | new_without_default = "allow" 9 | module_inception = "allow" 10 | needless_range_loop = "allow" 11 | type_complexity = "allow" 12 | dbg_macro = "warn" -------------------------------------------------------------------------------- /LICENSE-APACHE-2.0: -------------------------------------------------------------------------------- 1 | This license applies to all source files except those under /crates/gui. 2 | 3 | 4 | Apache License 5 | Version 2.0, January 2004 6 | http://www.apache.org/licenses/ 7 | 8 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 9 | 10 | 1. Definitions. 11 | 12 | "License" shall mean the terms and conditions for use, reproduction, 13 | and distribution as defined by Sections 1 through 9 of this document. 14 | 15 | "Licensor" shall mean the copyright owner or entity authorized by 16 | the copyright owner that is granting the License. 17 | 18 | "Legal Entity" shall mean the union of the acting entity and all 19 | other entities that control, are controlled by, or are under common 20 | control with that entity. For the purposes of this definition, 21 | "control" means (i) the power, direct or indirect, to cause the 22 | direction or management of such entity, whether by contract or 23 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 24 | outstanding shares, or (iii) beneficial ownership of such entity. 25 | 26 | "You" (or "Your") shall mean an individual or Legal Entity 27 | exercising permissions granted by this License. 28 | 29 | "Source" form shall mean the preferred form for making modifications, 30 | including but not limited to software source code, documentation 31 | source, and configuration files. 32 | 33 | "Object" form shall mean any form resulting from mechanical 34 | transformation or translation of a Source form, including but 35 | not limited to compiled object code, generated documentation, 36 | and conversions to other media types. 37 | 38 | "Work" shall mean the work of authorship, whether in Source or 39 | Object form, made available under the License, as indicated by a 40 | copyright notice that is included in or attached to the work 41 | (an example is provided in the Appendix below). 42 | 43 | "Derivative Works" shall mean any work, whether in Source or Object 44 | form, that is based on (or derived from) the Work and for which the 45 | editorial revisions, annotations, elaborations, or other modifications 46 | represent, as a whole, an original work of authorship. For the purposes 47 | of this License, Derivative Works shall not include works that remain 48 | separable from, or merely link (or bind by name) to the interfaces of, 49 | the Work and Derivative Works thereof. 50 | 51 | "Contribution" shall mean any work of authorship, including 52 | the original version of the Work and any modifications or additions 53 | to that Work or Derivative Works thereof, that is intentionally 54 | submitted to Licensor for inclusion in the Work by the copyright owner 55 | or by an individual or Legal Entity authorized to submit on behalf of 56 | the copyright owner. For the purposes of this definition, "submitted" 57 | means any form of electronic, verbal, or written communication sent 58 | to the Licensor or its representatives, including but not limited to 59 | communication on electronic mailing lists, source code control systems, 60 | and issue tracking systems that are managed by, or on behalf of, the 61 | Licensor for the purpose of discussing and improving the Work, but 62 | excluding communication that is conspicuously marked or otherwise 63 | designated in writing by the copyright owner as "Not a Contribution." 64 | 65 | "Contributor" shall mean Licensor and any individual or Legal Entity 66 | on behalf of whom a Contribution has been received by Licensor and 67 | subsequently incorporated within the Work. 68 | 69 | 2. Grant of Copyright License. Subject to the terms and conditions of 70 | this License, each Contributor hereby grants to You a perpetual, 71 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 72 | copyright license to reproduce, prepare Derivative Works of, 73 | publicly display, publicly perform, sublicense, and distribute the 74 | Work and such Derivative Works in Source or Object form. 75 | 76 | 3. Grant of Patent License. Subject to the terms and conditions of 77 | this License, each Contributor hereby grants to You a perpetual, 78 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 79 | (except as stated in this section) patent license to make, have made, 80 | use, offer to sell, sell, import, and otherwise transfer the Work, 81 | where such license applies only to those patent claims licensable 82 | by such Contributor that are necessarily infringed by their 83 | Contribution(s) alone or by combination of their Contribution(s) 84 | with the Work to which such Contribution(s) was submitted. If You 85 | institute patent litigation against any entity (including a 86 | cross-claim or counterclaim in a lawsuit) alleging that the Work 87 | or a Contribution incorporated within the Work constitutes direct 88 | or contributory patent infringement, then any patent licenses 89 | granted to You under this License for that Work shall terminate 90 | as of the date such litigation is filed. 91 | 92 | 4. Redistribution. You may reproduce and distribute copies of the 93 | Work or Derivative Works thereof in any medium, with or without 94 | modifications, and in Source or Object form, provided that You 95 | meet the following conditions: 96 | 97 | (a) You must give any other recipients of the Work or 98 | Derivative Works a copy of this License; and 99 | 100 | (b) You must cause any modified files to carry prominent notices 101 | stating that You changed the files; and 102 | 103 | (c) You must retain, in the Source form of any Derivative Works 104 | that You distribute, all copyright, patent, trademark, and 105 | attribution notices from the Source form of the Work, 106 | excluding those notices that do not pertain to any part of 107 | the Derivative Works; and 108 | 109 | (d) If the Work includes a "NOTICE" text file as part of its 110 | distribution, then any Derivative Works that You distribute must 111 | include a readable copy of the attribution notices contained 112 | within such NOTICE file, excluding those notices that do not 113 | pertain to any part of the Derivative Works, in at least one 114 | of the following places: within a NOTICE text file distributed 115 | as part of the Derivative Works; within the Source form or 116 | documentation, if provided along with the Derivative Works; or, 117 | within a display generated by the Derivative Works, if and 118 | wherever such third-party notices normally appear. The contents 119 | of the NOTICE file are for informational purposes only and 120 | do not modify the License. You may add Your own attribution 121 | notices within Derivative Works that You distribute, alongside 122 | or as an addendum to the NOTICE text from the Work, provided 123 | that such additional attribution notices cannot be construed 124 | as modifying the License. 125 | 126 | You may add Your own copyright statement to Your modifications and 127 | may provide additional or different license terms and conditions 128 | for use, reproduction, or distribution of Your modifications, or 129 | for any such Derivative Works as a whole, provided Your use, 130 | reproduction, and distribution of the Work otherwise complies with 131 | the conditions stated in this License. 132 | 133 | 5. Submission of Contributions. Unless You explicitly state otherwise, 134 | any Contribution intentionally submitted for inclusion in the Work 135 | by You to the Licensor shall be under the terms and conditions of 136 | this License, without any additional terms or conditions. 137 | Notwithstanding the above, nothing herein shall supersede or modify 138 | the terms of any separate license agreement you may have executed 139 | with Licensor regarding such Contributions. 140 | 141 | 6. Trademarks. This License does not grant permission to use the trade 142 | names, trademarks, service marks, or product names of the Licensor, 143 | except as required for reasonable and customary use in describing the 144 | origin of the Work and reproducing the content of the NOTICE file. 145 | 146 | 7. Disclaimer of Warranty. Unless required by applicable law or 147 | agreed to in writing, Licensor provides the Work (and each 148 | Contributor provides its Contributions) on an "AS IS" BASIS, 149 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 150 | implied, including, without limitation, any warranties or conditions 151 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 152 | PARTICULAR PURPOSE. You are solely responsible for determining the 153 | appropriateness of using or redistributing the Work and assume any 154 | risks associated with Your exercise of permissions under this License. 155 | 156 | 8. Limitation of Liability. In no event and under no legal theory, 157 | whether in tort (including negligence), contract, or otherwise, 158 | unless required by applicable law (such as deliberate and grossly 159 | negligent acts) or agreed to in writing, shall any Contributor be 160 | liable to You for damages, including any direct, indirect, special, 161 | incidental, or consequential damages of any character arising as a 162 | result of this License or out of the use or inability to use the 163 | Work (including but not limited to damages for loss of goodwill, 164 | work stoppage, computer failure or malfunction, or any and all 165 | other commercial damages or losses), even if such Contributor 166 | has been advised of the possibility of such damages. 167 | 168 | 9. Accepting Warranty or Additional Liability. While redistributing 169 | the Work or Derivative Works thereof, You may choose to offer, 170 | and charge a fee for, acceptance of support, warranty, indemnity, 171 | or other liability obligations and/or rights consistent with this 172 | License. However, in accepting such obligations, You may act only 173 | on Your own behalf and on Your sole responsibility, not on behalf 174 | of any other Contributor, and only if You agree to indemnify, 175 | defend, and hold each Contributor harmless for any liability 176 | incurred by, or claims asserted against, such Contributor by reason 177 | of your accepting any such warranty or additional liability. 178 | 179 | END OF TERMS AND CONDITIONS 180 | -------------------------------------------------------------------------------- /LICENSE-ISC: -------------------------------------------------------------------------------- 1 | This license applies to all source files except those under /crates/gui. 2 | 3 | Copyright © valadaptive 4 | 5 | Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby granted, provided that the above copyright notice and this permission notice appear in all copies. 6 | 7 | THE SOFTWARE IS PROVIDED “AS IS” AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. 8 | -------------------------------------------------------------------------------- /LICENSE-MIT: -------------------------------------------------------------------------------- 1 | This license applies to all source files except those under /crates/gui. 2 | 3 | Copyright © valadaptive 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /MAINTAINING.md: -------------------------------------------------------------------------------- 1 | # Drafting a new release 2 | 3 | 1. Bump the version of the `gui` crate. I don't update the version numbers of some other crates, even if maybe I should. This should be done even if there are no changes to the GUI itself--it's considered the "canonical" version number that shows up on the releases page. 4 | 2. Update the third-party credits and licenses for the GUI. Instructions for this are in that crate's README. 5 | 3. If you make changes to the OpenFX plugin, After Effects plugin, or effect settings, bump those versions as well. For the AE plugin, the plugin version is taken from the PiPL in build.rs and not the Cargo.toml. 6 | 4. Commit the changes and tag them. The tag will trigger a CI build that produces artifacts. 7 | 5. Once the CI build finishes, there will be a draft release on the Releases page. You'll need to provide a summary of the changes since last version, and then you can publish the release. 8 | 9 | # Various notes 10 | 11 | - You'll need to clone with `--recurse-submodules` (or use `git submodule update --init --recursive` in the repo if you've already cloned it) in order to build the OpenFX plugin, because it imports the `openfx` repo as a submodule. 12 | - Benchmarking is done with `RAYON_NUM_THREADS=1 cargo bench --bench filter_profile`. Benchmarks with multithreading enabled are probably also a good idea. 13 | - Development of the GUI will require gstreamer to be installed. You can do so following [the gstreamer-rs instructions](https://lib.rs/crates/gstreamer), though note that the MacOS instructions are a bit outdated and you can install it with `brew` just fine now. 14 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | 3 | 4 | 5 | ntsc-rs logo 6 | 7 | 8 |

9 | 10 | --- 11 | 12 | **ntsc-rs** is a video effect which emulates NTSC and VHS video artifacts. It can be used as an After Effects, Premiere, or OpenFX plugin, or as a standalone application. 13 | 14 | ![Screenshot of the ntsc-rs standalone application](./docs/img/appdemo.png) 15 | 16 | ## Download and Install 17 | 18 | The latest version of ntsc-rs can be downloaded from [the releases page](https://github.com/valadaptive/ntsc-rs/releases). 19 | 20 | After downloading, [read the documentation for how to run it](https://ntsc.rs/docs/standalone-installation/). In particular, ntsc-rs will not work properly on Linux unless you install all of the GStreamer packages listed in the documentation. 21 | 22 | ## More information 23 | 24 | ntsc-rs is a rough Rust port of [ntscqt](https://github.com/JargeZ/ntscqt), a PyQt-based GUI for [ntsc](https://github.com/zhuker/ntsc), itself a Python port of [composite-video-simulator](https://github.com/joncampbell123/composite-video-simulator). Reimplementing the image processing in multithreaded Rust allows it to run at (mostly) real-time speeds. 25 | 26 | It's not an exact port--some processing passes have visibly different results, and some new ones have been added. 27 | -------------------------------------------------------------------------------- /about.toml: -------------------------------------------------------------------------------- 1 | accepted = [ 2 | "Apache-2.0", 3 | "Apache-2.0 WITH LLVM-exception", 4 | "MIT", 5 | "ISC", 6 | "BSD-2-Clause", 7 | "BSD-3-Clause", 8 | "BSL-1.0", 9 | "OFL-1.1", 10 | "LicenseRef-UFL-1.0", 11 | "GPL-3.0", 12 | "CDDL-1.0", 13 | "Zlib", 14 | "Unicode-DFS-2016", 15 | "Unicode-3.0", 16 | "MPL-2.0", 17 | "Ubuntu-font-1.0", 18 | ] 19 | -------------------------------------------------------------------------------- /assets/icon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/valadaptive/ntsc-rs/93e533d2564d86b283ca22f119db34b538a33049/assets/icon.ico -------------------------------------------------------------------------------- /assets/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/valadaptive/ntsc-rs/93e533d2564d86b283ca22f119db34b538a33049/assets/icon.png -------------------------------------------------------------------------------- /assets/icon.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /assets/install_scripts/postinstall: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | set -e 3 | 4 | PLUGIN_BUNDLE="$DSTROOT/ntsc-rs.plugin" 5 | 6 | if [ -d "$PLUGIN_BUNDLE" ]; then 7 | echo "Signing After Effects plugin with ad-hoc signature..." 8 | /usr/bin/codesign --force --deep --sign - "$PLUGIN_BUNDLE" 9 | echo "After Effects plugin signed successfully." 10 | else 11 | echo "After Effects plugin not found at expected location. Skipping signing." 12 | exit 1 13 | fi 14 | 15 | exit 0 16 | -------------------------------------------------------------------------------- /assets/macos_icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/valadaptive/ntsc-rs/93e533d2564d86b283ca22f119db34b538a33049/assets/macos_icon.png -------------------------------------------------------------------------------- /assets/macos_icon_less_detail.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/valadaptive/ntsc-rs/93e533d2564d86b283ca22f119db34b538a33049/assets/macos_icon_less_detail.png -------------------------------------------------------------------------------- /crates/ae-plugin/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "ae-plugin" 3 | version = "0.1.0" 4 | edition = "2021" 5 | license = "MIT OR ISC OR Apache-2.0" 6 | 7 | [lib] 8 | crate-type = ["cdylib"] 9 | 10 | [target.'cfg(any(windows, target_os="macos"))'.dependencies] 11 | after-effects = {git = "https://github.com/virtualritz/after-effects", rev = "c70729a", features = ["catch-panics"]} 12 | premiere = {git = "https://github.com/virtualritz/after-effects", rev = "c70729a"} 13 | ntscrs = { path = "../ntscrs" } 14 | 15 | [target.'cfg(any(windows, target_os="macos"))'.build-dependencies] 16 | pipl = {git = "https://github.com/virtualritz/after-effects", rev = "c70729a"} 17 | 18 | [dependencies] 19 | raw-window-handle = "0.6.2" 20 | rfd = "0.15.3" 21 | 22 | [lints] 23 | workspace = true 24 | -------------------------------------------------------------------------------- /crates/ae-plugin/README.md: -------------------------------------------------------------------------------- 1 | # ntsc-rs After Effects plugin 2 | 3 | ntsc-rs can be used from After Effects and Premiere! When built, the plugin can be found under the "Stylize" category. 4 | 5 | ## Building the plugin 6 | 7 | See [the documentation on the ntsc-rs website](https://ntsc.rs/docs/building-from-source/) for up-to-date information. 8 | -------------------------------------------------------------------------------- /crates/ae-plugin/build.rs: -------------------------------------------------------------------------------- 1 | #[rustfmt::skip] 2 | fn main() { 3 | #[cfg(any(windows, target_os = "macos"))] 4 | { 5 | const PF_PLUG_IN_VERSION: u16 = 13; 6 | const PF_PLUG_IN_SUBVERS: u16 = 28; 7 | 8 | const EFFECT_VERSION_MAJOR: u32 = 1; 9 | const EFFECT_VERSION_MINOR: u32 = 5; 10 | const EFFECT_VERSION_PATCH: u32 = 3; 11 | use pipl::*; 12 | pipl::plugin_build(vec![ 13 | Property::Kind(PIPLType::AEEffect), 14 | Property::Name("NTSC-rs"), 15 | Property::Category("Stylize"), 16 | 17 | #[cfg(target_os = "windows")] 18 | Property::CodeWin64X86("EffectMain"), 19 | #[cfg(target_os = "macos")] 20 | Property::CodeMacIntel64("EffectMain"), 21 | #[cfg(target_os = "macos")] 22 | Property::CodeMacARM64("EffectMain"), 23 | 24 | Property::AE_PiPL_Version { major: 2, minor: 0 }, 25 | Property::AE_Effect_Spec_Version { major: PF_PLUG_IN_VERSION, minor: PF_PLUG_IN_SUBVERS }, 26 | Property::AE_Effect_Version { 27 | version: EFFECT_VERSION_MAJOR, 28 | subversion: EFFECT_VERSION_MINOR, 29 | bugversion: EFFECT_VERSION_PATCH, 30 | stage: Stage::Develop, 31 | build: 1, 32 | }, 33 | Property::AE_Effect_Info_Flags(0), 34 | Property::AE_Effect_Global_OutFlags( 35 | OutFlags::NonParamVary | 36 | OutFlags::DeepColorAware | 37 | OutFlags::SendUpdateParamsUI 38 | ), 39 | Property::AE_Effect_Global_OutFlags_2( 40 | OutFlags2::ParamGroupStartCollapsedFlag | 41 | OutFlags2::SupportsSmartRender | 42 | OutFlags2::FloatColorAware | 43 | OutFlags2::RevealsZeroAlpha | 44 | OutFlags2::SupportsThreadedRendering | 45 | OutFlags2::SupportsGetFlattenedSequenceData 46 | ), 47 | Property::AE_Effect_Match_Name("ntsc-rs"), 48 | Property::AE_Reserved_Info(8), 49 | Property::AE_Effect_Support_URL("https://ntsc.rs/docs/after-effects-plugin/"), 50 | ]); 51 | println!("cargo:rustc-env=EFFECT_VERSION_MAJOR={EFFECT_VERSION_MAJOR}"); 52 | println!("cargo:rustc-env=EFFECT_VERSION_MINOR={EFFECT_VERSION_MINOR}"); 53 | println!("cargo:rustc-env=EFFECT_VERSION_PATCH={EFFECT_VERSION_PATCH}"); 54 | println!("cargo:rustc-cfg=with_premiere"); 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /crates/ae-plugin/src/handle.rs: -------------------------------------------------------------------------------- 1 | use core::slice; 2 | use std::{ 3 | borrow::{Borrow, BorrowMut}, 4 | marker::PhantomData, 5 | mem::{MaybeUninit, transmute}, 6 | ptr::NonNull, 7 | }; 8 | 9 | use after_effects::{Error, suites}; 10 | 11 | #[cfg(not(target_os = "macos"))] 12 | type HandleInner = *mut std::os::raw::c_void; 13 | #[cfg(target_os = "macos")] 14 | type HandleInner = *mut std::os::raw::c_char; 15 | 16 | pub struct SliceHandle { 17 | handle: NonNull, 18 | _ty: PhantomData, 19 | len: usize, 20 | suite: suites::Handle, 21 | } 22 | 23 | impl SliceHandle { 24 | pub fn new_uninit(len: usize) -> Result>, Error> { 25 | let handle_suite = suites::Handle::new()?; 26 | let handle = NonNull::new(handle_suite.new_handle((len * std::mem::size_of::()) as u64)) 27 | .ok_or(Error::OutOfMemory)?; 28 | 29 | Ok(SliceHandle { 30 | handle: handle as _, 31 | _ty: PhantomData, 32 | len, 33 | suite: handle_suite, 34 | }) 35 | } 36 | 37 | pub fn lock(&mut self) -> Result, Error> { 38 | let ptr = self.suite.lock_handle(self.handle.as_ptr()); 39 | let data = NonNull::new(ptr as *mut T).ok_or(Error::BadCallbackParameter)?; 40 | 41 | Ok(LockedHandle { data, parent: self }) 42 | } 43 | } 44 | impl Drop for SliceHandle { 45 | fn drop(&mut self) { 46 | self.suite.dispose_handle(self.handle.as_ptr()); 47 | } 48 | } 49 | 50 | impl SliceHandle { 51 | pub fn new(len: usize, value: T) -> Result { 52 | let mut handle = Self::new_uninit(len)?; 53 | 54 | { 55 | let mut locked = handle.lock()?; 56 | let data: &mut [MaybeUninit] = locked.borrow_mut(); 57 | for i in data.iter_mut() { 58 | i.write(value); 59 | } 60 | } 61 | 62 | Ok(unsafe { transmute::>, SliceHandle>(handle) }) 63 | } 64 | } 65 | 66 | pub struct LockedHandle<'a, T> { 67 | data: NonNull, 68 | parent: &'a mut SliceHandle, 69 | } 70 | 71 | impl Borrow<[T]> for LockedHandle<'_, T> { 72 | fn borrow(&self) -> &[T] { 73 | unsafe { slice::from_raw_parts(self.data.as_ptr(), self.parent.len) } 74 | } 75 | } 76 | 77 | impl BorrowMut<[T]> for LockedHandle<'_, T> { 78 | fn borrow_mut(&mut self) -> &mut [T] { 79 | unsafe { slice::from_raw_parts_mut(self.data.as_ptr(), self.parent.len) } 80 | } 81 | } 82 | 83 | impl Drop for LockedHandle<'_, T> { 84 | fn drop(&mut self) { 85 | self.parent.suite.unlock_handle(self.parent.handle.as_ptr()); 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /crates/ae-plugin/src/window_handle.rs: -------------------------------------------------------------------------------- 1 | use raw_window_handle::{ 2 | DisplayHandle, HandleError, HasDisplayHandle, HasWindowHandle, RawWindowHandle, 3 | Win32WindowHandle, WindowHandle, 4 | }; 5 | 6 | pub struct WindowAndDisplayHandle { 7 | raw_handle: Win32WindowHandle, 8 | } 9 | 10 | impl WindowAndDisplayHandle { 11 | /// Safety: The window handle must be valid for however long you intend to use it for 12 | pub unsafe fn new(raw_handle: Win32WindowHandle) -> Self { 13 | Self { raw_handle } 14 | } 15 | } 16 | 17 | impl HasWindowHandle for WindowAndDisplayHandle { 18 | fn window_handle(&self) -> Result, HandleError> { 19 | Ok(unsafe { WindowHandle::borrow_raw(RawWindowHandle::Win32(self.raw_handle)) }) 20 | } 21 | } 22 | 23 | impl HasDisplayHandle for WindowAndDisplayHandle { 24 | fn display_handle(&self) -> Result, HandleError> { 25 | Ok(DisplayHandle::windows()) 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /crates/gui/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "gui" 3 | version = "0.9.3" 4 | edition = "2024" 5 | default-run = "ntsc-rs-standalone" 6 | license = "GPL-3.0" 7 | repository = "https://github.com/valadaptive/ntsc-rs/tree/main/crates/gui" 8 | 9 | [dependencies] 10 | ntscrs = { path = "../ntscrs" } 11 | eframe = { version = "0.31.1", features = ["persistence", "x11", "wayland"] } 12 | env_logger = "0.11.3" 13 | fontique = { git = "https://github.com/valadaptive/parley", rev = "a3b6cee" } 14 | snafu = "0.8.3" 15 | logos = "0.15" 16 | gstreamer = {version = "0.23", features = ["serde"]} 17 | gstreamer-base = "0.23" 18 | gstreamer-video = "0.23" 19 | gstreamer-pbutils = "0.23" 20 | gst-plugin-webp = { version = "0.13.4", features = ["static"] } 21 | futures-lite = "2.3.0" 22 | async-executor = "1.12.0" 23 | log = "0.4.20" 24 | rfd = { version = "0.15.3", default-features = false, features = ["xdg-portal", "async-std"] } 25 | rand = "0.9.0" 26 | tinyjson = "2.5.1" 27 | open = "5.1.4" 28 | serde = "1.0" 29 | trash = "5.0.0" 30 | blocking = "1.6.1" 31 | clap = { version = "4.5.17", features = ["cargo", "string"] } 32 | color-eyre = "0.6.3" 33 | console = "0.15.8" 34 | 35 | [build-dependencies] 36 | embed-resource = "3.0.1" 37 | 38 | [[bin]] 39 | name = "ntsc-rs-standalone" 40 | 41 | [[bin]] 42 | name = "ntsc-rs-launcher" 43 | 44 | [[bin]] 45 | name = "ntsc-rs-cli" 46 | 47 | [lints] 48 | workspace = true 49 | -------------------------------------------------------------------------------- /crates/gui/README.md: -------------------------------------------------------------------------------- 1 | # ntsc-rs standalone GUI 2 | 3 | ntsc-rs can be used as a standalone GUI application. It uses gstreamer for media encoding, decoding, and playback. 4 | 5 | ## Building 6 | 7 | See [the documentation on the ntsc-rs website](https://ntsc.rs/docs/building-from-source/) for up-to-date information. 8 | 9 | ## Updating third-party license credits 10 | 11 | After installing or updating Cargo dependencies, you'll need to regenerate the list of third-party licenses using [cargo-about](https://github.com/EmbarkStudios/cargo-about): 12 | 13 | ```bash 14 | $ cargo about generate --format=json -o about.json 15 | ``` 16 | 17 | when inside the `gui` crate folder. 18 | 19 | If you get a "failed to satisfy license requirements" error, you'll need to add the failing third-party crate's license identifier to [`about.toml`](../../about.toml). -------------------------------------------------------------------------------- /crates/gui/build.rs: -------------------------------------------------------------------------------- 1 | use std::env; 2 | extern crate embed_resource; 3 | 4 | fn main() { 5 | if env::var_os("CARGO_CFG_WINDOWS").is_some() { 6 | embed_resource::compile("icon.rc", embed_resource::NONE) 7 | .manifest_required() 8 | .unwrap(); 9 | } 10 | 11 | if env::var("CARGO_CFG_TARGET_OS").is_ok_and(|os| os == "macos") { 12 | println!("cargo:rustc-link-arg=-headerpad_max_install_names"); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /crates/gui/icon.rc: -------------------------------------------------------------------------------- 1 | ntscrs_icon ICON "..\\..\\assets\\icon.ico" -------------------------------------------------------------------------------- /crates/gui/src/app/app_state.rs: -------------------------------------------------------------------------------- 1 | use std::thread::JoinHandle; 2 | 3 | use eframe::egui::{Rect, pos2}; 4 | use serde::{Deserialize, Serialize}; 5 | use snafu::ResultExt; 6 | 7 | use crate::gst_utils::{gstreamer_error::GstreamerError, ntsc_pipeline::VideoScale}; 8 | 9 | use super::error::{ApplicationError, GstreamerInitSnafu}; 10 | 11 | #[derive(Debug)] 12 | pub struct VideoZoom { 13 | pub scale: f64, 14 | pub fit: bool, 15 | } 16 | 17 | #[derive(Debug, Serialize, Deserialize)] 18 | pub struct VideoScaleState { 19 | pub scale: VideoScale, 20 | pub enabled: bool, 21 | } 22 | 23 | impl Default for VideoScaleState { 24 | fn default() -> Self { 25 | Self { 26 | scale: Default::default(), 27 | enabled: true, 28 | } 29 | } 30 | } 31 | 32 | #[derive(Debug)] 33 | pub struct AudioVolume { 34 | pub gain: f64, 35 | // If the user drags the volume slider all the way to 0, we want to keep track of what it was before they did that 36 | // so we can reset the volume to it when they click the unmute button. This prevents e.g. the user setting the 37 | // volume to 25%, dragging it down to 0%, then clicking unmute and having it reset to some really loud default 38 | // value. 39 | pub gain_pre_mute: f64, 40 | pub mute: bool, 41 | } 42 | 43 | #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] 44 | pub enum EffectPreviewMode { 45 | #[default] 46 | Enabled, 47 | Disabled, 48 | SplitScreen, 49 | } 50 | 51 | #[derive(Debug)] 52 | pub struct EffectPreviewSettings { 53 | pub mode: EffectPreviewMode, 54 | pub preview_rect: Rect, 55 | } 56 | 57 | impl Default for EffectPreviewSettings { 58 | fn default() -> Self { 59 | Self { 60 | mode: Default::default(), 61 | preview_rect: Rect::from_min_max(pos2(0.0, 0.0), pos2(0.5, 1.0)), 62 | } 63 | } 64 | } 65 | 66 | impl Default for AudioVolume { 67 | fn default() -> Self { 68 | Self { 69 | gain: 1.0, 70 | gain_pre_mute: 1.0, 71 | mute: false, 72 | } 73 | } 74 | } 75 | 76 | #[derive(Default, PartialEq, Eq)] 77 | pub enum LeftPanelState { 78 | #[default] 79 | EffectSettings, 80 | RenderSettings, 81 | } 82 | 83 | /// Used for the loading screen (and error screen if GStreamer fails to initialize). We initialize GStreamer on its own 84 | /// thread, and return the result via a JoinHandle. 85 | #[derive(Debug)] 86 | pub enum GstreamerInitState { 87 | Initializing(Option>>), 88 | Initialized(Result<(), ApplicationError>), 89 | } 90 | 91 | impl GstreamerInitState { 92 | pub fn check(&mut self) -> &mut Self { 93 | if let Self::Initializing(handle) = self { 94 | if handle.as_ref().is_some_and(|h| h.is_finished()) { 95 | // In order to be able to "move" the error between enum variants, we need to be able to mem::take the 96 | // join handle. 97 | let res = handle 98 | .take() 99 | .unwrap() 100 | .join() 101 | .unwrap() 102 | .context(GstreamerInitSnafu); 103 | *self = Self::Initialized(res); 104 | } 105 | } 106 | 107 | self 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /crates/gui/src/app/dnd_overlay.rs: -------------------------------------------------------------------------------- 1 | use eframe::egui::{self, DroppedFile}; 2 | 3 | pub trait UiDndExt { 4 | fn show_dnd_overlay(&mut self, text: impl Into) -> Option>; 5 | } 6 | 7 | impl UiDndExt for egui::Ui { 8 | fn show_dnd_overlay(&mut self, text: impl Into) -> Option> { 9 | let max_rect = self.max_rect(); 10 | 11 | let pointer_in_drop_area = self.ctx().input(|input| { 12 | input 13 | .pointer 14 | .latest_pos() 15 | .is_some_and(|p| max_rect.contains(p)) 16 | }); 17 | 18 | if pointer_in_drop_area { 19 | let files = self.ctx().take_dropped_files_last_frame(); 20 | if files.is_some() { 21 | return files; 22 | } 23 | } 24 | 25 | let dragging_files = self 26 | .ctx() 27 | .input(|input| !input.raw.hovered_files.is_empty()); 28 | if !dragging_files { 29 | return None; 30 | } 31 | 32 | let overlay_size = max_rect.size(); 33 | let area = egui::Area::new(self.auto_id_with("dnd_overlay")) 34 | .fixed_pos(max_rect.left_top()) 35 | .default_size(overlay_size); 36 | area.show(self.ctx(), |ui| { 37 | ui.allocate_ui(overlay_size, |ui| { 38 | egui::Frame::NONE 39 | .fill(ui.visuals().extreme_bg_color.gamma_multiply(0.8)) 40 | .stroke(ui.style().visuals.widgets.noninteractive.bg_stroke) 41 | .show(ui, |ui| { 42 | ui.centered_and_justified(|ui| { 43 | ui.heading(text); 44 | }); 45 | }); 46 | }); 47 | }); 48 | 49 | None 50 | } 51 | } 52 | 53 | pub trait CtxDndExt { 54 | fn update_dnd_state(&self); 55 | fn take_dropped_files_last_frame(&self) -> Option>; 56 | } 57 | 58 | impl CtxDndExt for egui::Context { 59 | fn update_dnd_state(&self) { 60 | // Due to event order, dropped files may come in before the pointer position is updated. To avoid this, we need 61 | // to delay handling them by one frame. 62 | // 63 | // TODO: Remove this once winit 0.31 comes out and its DnD rework makes it into egui. 64 | let files_id = egui::Id::new("dropped_files_last_frame"); 65 | 66 | let dropped_files = self.input_mut(|input| { 67 | if input.raw.dropped_files.is_empty() { 68 | None 69 | } else { 70 | Some(std::mem::take(&mut input.raw.dropped_files)) 71 | } 72 | }); 73 | 74 | self.data_mut(|data| { 75 | data.remove_temp::>(files_id); 76 | 77 | if let Some(dropped_files) = dropped_files { 78 | data.insert_temp(files_id, dropped_files); 79 | } 80 | }); 81 | } 82 | 83 | fn take_dropped_files_last_frame(&self) -> Option> { 84 | let files_id = egui::Id::new("dropped_files_last_frame"); 85 | self.data_mut(|data| data.remove_temp::>(files_id)) 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /crates/gui/src/app/error.rs: -------------------------------------------------------------------------------- 1 | use ntscrs::settings::{ParseSettingsError, sval_json}; 2 | use snafu::Snafu; 3 | 4 | use crate::gst_utils::{gstreamer_error::GstreamerError, ntsc_pipeline::PipelineError}; 5 | 6 | #[derive(Debug, Snafu)] 7 | #[snafu(visibility(pub(crate)))] 8 | pub enum ApplicationError { 9 | #[snafu(display("Error initializing GStreamer: {source}"))] 10 | GstreamerInit { source: GstreamerError }, 11 | 12 | #[snafu(display("Error loading video: {source}"))] 13 | LoadVideo { source: GstreamerError }, 14 | 15 | #[snafu(display("Error creating pipeline: {source}"))] 16 | CreatePipeline { source: PipelineError }, 17 | 18 | #[snafu(display("Error creating render job: {source}"))] 19 | CreateRenderJob { source: GstreamerError }, 20 | 21 | #[snafu(display("Error during render job: {source}"))] 22 | RenderJobPipeline { source: GstreamerError }, 23 | 24 | #[snafu(display("Error reading JSON: {source}"))] 25 | JSONRead { source: std::io::Error }, 26 | 27 | #[snafu(display("Error parsing JSON: {source}"))] 28 | JSONParse { 29 | #[snafu(source(from(ParseSettingsError, Box::new)))] 30 | source: Box, 31 | }, 32 | 33 | #[snafu(display("Error creating presets directory: {source}"))] 34 | CreatePresetsDirectory { source: std::io::Error }, 35 | 36 | #[snafu(display("Error creating preset: {source}"))] 37 | CreatePresetFile { source: std::io::Error }, 38 | 39 | #[snafu(display("Error creating preset: {source}"))] 40 | CreatePresetJSON { source: sval_json::Error }, 41 | 42 | #[snafu(display("Error deleting preset: {source}"))] 43 | DeletePreset { 44 | #[snafu(source(from(trash::Error, Box::new)))] 45 | source: Box, 46 | }, 47 | 48 | #[snafu(display("Error renaming preset: {source}"))] 49 | RenamePreset { source: std::io::Error }, 50 | 51 | #[snafu(display("Error installing preset: {source}"))] 52 | InstallPreset { source: std::io::Error }, 53 | 54 | #[snafu(display("Filesystem error: {source}"))] 55 | Fs { source: std::io::Error }, 56 | 57 | #[snafu(display("Only one file at a time can be dropped here"))] 58 | DroppedMultipleFiles, 59 | } 60 | -------------------------------------------------------------------------------- /crates/gui/src/app/executor.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | pin::Pin, 3 | sync::{Arc, Mutex, Weak}, 4 | task::{Context, Poll, Wake, Waker}, 5 | }; 6 | 7 | use async_executor::{Executor, Task}; 8 | use eframe::egui; 9 | use futures_lite::{Future, FutureExt}; 10 | use gstreamer::glib::clone::Downgrade; 11 | use log::trace; 12 | 13 | use super::{AppFn, ApplessFn, NtscApp}; 14 | 15 | struct AppExecutorInner { 16 | executor: Arc>, 17 | exec_future: Pin + Send>>, 18 | waker: Waker, 19 | egui_ctx: egui::Context, 20 | tasks: Vec>>, 21 | } 22 | 23 | impl AppExecutorInner { 24 | pub fn spawn(&mut self, future: impl Future> + 'static + Send) { 25 | trace!( 26 | "spawned task on frame {}", 27 | self.egui_ctx.cumulative_pass_nr() 28 | ); 29 | let task = self.executor.spawn(future); 30 | self.tasks.push(task); 31 | self.egui_ctx.request_repaint(); 32 | } 33 | } 34 | 35 | struct AppWaker(egui::Context); 36 | 37 | impl Wake for AppWaker { 38 | fn wake(self: Arc) { 39 | self.0.request_repaint(); 40 | } 41 | 42 | fn wake_by_ref(self: &Arc) { 43 | self.0.request_repaint(); 44 | } 45 | } 46 | 47 | pub struct AppExecutor(Arc>); 48 | 49 | impl AppExecutor { 50 | pub fn new(egui_ctx: egui::Context) -> Self { 51 | let executor = Arc::new(Executor::new()); 52 | let executor_for_future = Arc::clone(&executor); 53 | let egui_ctx_for_waker = egui_ctx.clone(); 54 | Self(Arc::new(Mutex::new(AppExecutorInner { 55 | executor, 56 | exec_future: Box::pin(async move { 57 | executor_for_future 58 | .run(futures_lite::future::pending()) 59 | .await 60 | }), 61 | waker: Waker::from(Arc::new(AppWaker(egui_ctx_for_waker))), 62 | egui_ctx, 63 | tasks: Vec::new(), 64 | }))) 65 | } 66 | 67 | #[must_use] 68 | pub fn tick(&self) -> Vec { 69 | let exec = &mut *self.0.lock().unwrap(); 70 | 71 | let mut context = Context::from_waker(&exec.waker); 72 | let _ = exec.exec_future.poll(&mut context); 73 | 74 | let mut queued = Vec::new(); 75 | 76 | exec.tasks.retain_mut(|task| match task.poll(&mut context) { 77 | Poll::Ready(cb) => { 78 | trace!( 79 | "finished task on frame {}", 80 | exec.egui_ctx.cumulative_pass_nr() 81 | ); 82 | if let Some(cb) = cb { 83 | queued.push(cb); 84 | } 85 | false 86 | } 87 | Poll::Pending => true, 88 | }); 89 | 90 | queued 91 | } 92 | 93 | pub fn spawn(&self, future: impl Future> + 'static + Send) { 94 | let exec = &mut *self.0.lock().unwrap(); 95 | exec.spawn(future); 96 | } 97 | 98 | pub fn make_spawner(&self) -> AppTaskSpawner { 99 | AppTaskSpawner(self.0.downgrade()) 100 | } 101 | } 102 | 103 | #[derive(Clone)] 104 | pub struct AppTaskSpawner(Weak>); 105 | 106 | impl AppTaskSpawner { 107 | pub fn spawn(&self, future: impl Future> + 'static + Send) { 108 | let Some(exec) = self.0.upgrade() else { 109 | return; 110 | }; 111 | 112 | exec.lock().unwrap().spawn(future); 113 | } 114 | } 115 | 116 | pub trait ApplessExecutor: Send + Sync { 117 | fn spawn(&self, future: impl Future> + 'static + Send); 118 | } 119 | 120 | impl ApplessExecutor for AppExecutor { 121 | fn spawn(&self, future: impl Future> + 'static + Send) { 122 | self.spawn(async { future.await.map(|cb| Box::new(|_: &mut NtscApp| cb()) as _) }); 123 | } 124 | } 125 | 126 | impl ApplessExecutor for AppTaskSpawner { 127 | fn spawn(&self, future: impl Future> + 'static + Send) { 128 | self.spawn(async { future.await.map(|cb| Box::new(|_: &mut NtscApp| cb()) as _) }); 129 | } 130 | } 131 | -------------------------------------------------------------------------------- /crates/gui/src/app/format_eta.rs: -------------------------------------------------------------------------------- 1 | use std::fmt::Write; 2 | 3 | pub fn format_eta( 4 | mut dest: impl Write, 5 | time_remaining: f64, 6 | units: [[&str; 2]; 3], 7 | separator: &str, 8 | ) { 9 | let mut time_remaining = time_remaining.ceil() as u64; 10 | let hrs = time_remaining / (60 * 60); 11 | time_remaining %= 60 * 60; 12 | let min = time_remaining / 60; 13 | time_remaining %= 60; 14 | let sec = time_remaining; 15 | 16 | let mut write_unit = |value, singular, plural, separator| { 17 | let unit_label = match value { 18 | 1 => singular, 19 | _ => plural, 20 | }; 21 | write!(dest, "{value}{unit_label}{separator}").unwrap(); 22 | }; 23 | 24 | let [ 25 | [hours_singular, hours_plural], 26 | [minutes_singular, minutes_plural], 27 | [seconds_singular, seconds_plural], 28 | ] = units; 29 | if hrs > 0 { 30 | write_unit(hrs, hours_singular, hours_plural, separator); 31 | } 32 | if min > 0 { 33 | write_unit(min, minutes_singular, minutes_plural, separator); 34 | } 35 | write_unit(sec, seconds_singular, seconds_plural, ""); 36 | } 37 | -------------------------------------------------------------------------------- /crates/gui/src/app/layout_helper.rs: -------------------------------------------------------------------------------- 1 | use eframe::egui::{self, vec2}; 2 | 3 | pub trait LayoutHelper { 4 | fn ltr(&mut self, add_contents: impl FnOnce(&mut Self) -> R) -> egui::InnerResponse; 5 | fn rtl(&mut self, add_contents: impl FnOnce(&mut Self) -> R) -> egui::InnerResponse; 6 | } 7 | 8 | fn ui_with_layout<'c, R>( 9 | ui: &mut egui::Ui, 10 | layout: egui::Layout, 11 | add_contents: Box R + 'c>, 12 | ) -> egui::InnerResponse { 13 | let initial_size = vec2( 14 | ui.available_size_before_wrap().x, 15 | ui.spacing().interact_size.y, 16 | ); 17 | 18 | ui.allocate_ui_with_layout(initial_size, layout, |ui| add_contents(ui)) 19 | } 20 | 21 | impl LayoutHelper for egui::Ui { 22 | fn ltr(&mut self, add_contents: impl FnOnce(&mut egui::Ui) -> R) -> egui::InnerResponse { 23 | ui_with_layout( 24 | self, 25 | egui::Layout::left_to_right(egui::Align::Center), 26 | Box::new(add_contents), 27 | ) 28 | } 29 | 30 | fn rtl(&mut self, add_contents: impl FnOnce(&mut egui::Ui) -> R) -> egui::InnerResponse { 31 | ui_with_layout( 32 | self, 33 | egui::Layout::right_to_left(egui::Align::Center), 34 | Box::new(add_contents), 35 | ) 36 | } 37 | } 38 | 39 | pub trait TopBottomPanelExt { 40 | fn interact_height(self, ctx: &egui::Context) -> Self; 41 | fn interact_height_tall(self, ctx: &egui::Context) -> Self; 42 | } 43 | 44 | impl TopBottomPanelExt for egui::TopBottomPanel { 45 | fn interact_height(self, ctx: &egui::Context) -> Self { 46 | let mut frame = egui::Frame::side_top_panel(&ctx.style()); 47 | frame.inner_margin.top = 3; 48 | frame.inner_margin.bottom = 3; 49 | self.exact_height( 50 | ctx.style().spacing.interact_size.y 51 | + frame.inner_margin.sum().y 52 | + frame.stroke.width * 2.0 53 | + frame.outer_margin.sum().y, 54 | ) 55 | .frame(frame) 56 | } 57 | 58 | fn interact_height_tall(self, ctx: &egui::Context) -> Self { 59 | let mut frame = egui::Frame::side_top_panel(&ctx.style()); 60 | frame.inner_margin.top = 0; 61 | frame.inner_margin.bottom = 0; 62 | self.exact_height(ctx.style().spacing.interact_size.y * 2.0) 63 | .frame(frame) 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /crates/gui/src/app/license_dialog.rs: -------------------------------------------------------------------------------- 1 | use eframe::egui; 2 | 3 | use super::NtscApp; 4 | 5 | const LICENSE_TEXT: &str = include_str!("../../LICENSE"); 6 | 7 | impl NtscApp { 8 | pub fn show_license_dialog(&mut self, ctx: &egui::Context) { 9 | egui::Window::new("License") 10 | .open(&mut self.license_dialog_open) 11 | .default_width(500.0) 12 | .default_height(400.0) 13 | .show(ctx, |ui| { 14 | egui::ScrollArea::vertical() 15 | .auto_shrink([false, false]) 16 | .show(ui, |ui| { 17 | ui.monospace(LICENSE_TEXT); 18 | }); 19 | }); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /crates/gui/src/app/mod.rs: -------------------------------------------------------------------------------- 1 | use std::cell::RefCell; 2 | 3 | use app_state::GstreamerInitState; 4 | use eframe::egui::util::undoer::Undoer; 5 | use ntscrs::{ 6 | ntsc::NtscEffectFullSettings, 7 | settings::{SettingsList, easy::EasyModeFullSettings}, 8 | }; 9 | use presets::PresetsState; 10 | 11 | pub mod app_state; 12 | pub mod dnd_overlay; 13 | pub mod error; 14 | pub mod executor; 15 | pub mod format_eta; 16 | pub mod layout_helper; 17 | pub mod license_dialog; 18 | pub mod main; 19 | pub mod pipeline_info; 20 | pub mod presets; 21 | pub mod render_job; 22 | pub mod render_settings; 23 | pub mod system_fonts; 24 | pub mod third_party_licenses_dialog; 25 | pub mod ui_context; 26 | 27 | pub type AppFn = Box Result<(), error::ApplicationError> + Send>; 28 | pub type ApplessFn = Box Result<(), error::ApplicationError> + Send>; 29 | 30 | pub struct NtscApp { 31 | pub gstreamer_init: GstreamerInitState, 32 | pub settings_list: SettingsList, 33 | pub settings_list_easy: SettingsList, 34 | pub executor: executor::AppExecutor, 35 | pub pipeline: Option, 36 | pub undoer: Undoer, 37 | pub video_zoom: app_state::VideoZoom, 38 | pub video_scale: app_state::VideoScaleState, 39 | pub audio_volume: app_state::AudioVolume, 40 | pub effect_preview: app_state::EffectPreviewSettings, 41 | pub left_panel_state: app_state::LeftPanelState, 42 | pub easy_mode_enabled: bool, 43 | pub effect_settings: NtscEffectFullSettings, 44 | pub easy_mode_settings: EasyModeFullSettings, 45 | pub presets_state: PresetsState, 46 | pub render_settings: render_settings::RenderSettings, 47 | pub render_jobs: Vec, 48 | pub settings_json_paste: String, 49 | pub last_error: RefCell>, 50 | pub credits_dialog_open: bool, 51 | pub third_party_licenses_dialog_open: bool, 52 | pub license_dialog_open: bool, 53 | pub image_sequence_dialog_queued_render_job: Option< 54 | Box Result>, 55 | >, 56 | } 57 | -------------------------------------------------------------------------------- /crates/gui/src/app/pipeline_info.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | path::PathBuf, 3 | sync::{Arc, Mutex}, 4 | }; 5 | 6 | use gstreamer::ClockTime; 7 | use gstreamer_video::VideoInterlaceMode; 8 | 9 | use crate::gst_utils::{ 10 | elements::EguiSink, 11 | gstreamer_error::GstreamerError, 12 | ntsc_pipeline::{NtscPipeline, PipelineError}, 13 | }; 14 | 15 | #[derive(Debug, Default)] 16 | pub struct PipelineMetadata { 17 | pub is_still_image: Option, 18 | pub has_audio: Option, 19 | pub framerate: Option, 20 | pub interlace_mode: Option, 21 | pub resolution: Option<(usize, usize)>, 22 | } 23 | 24 | #[derive(Debug)] 25 | pub enum PipelineStatus { 26 | Loading, 27 | Loaded, 28 | Error(PipelineError), 29 | } 30 | 31 | pub struct PipelineInfo { 32 | pub pipeline: NtscPipeline, 33 | pub state: Arc>, 34 | pub path: PathBuf, 35 | pub egui_sink: EguiSink, 36 | pub last_seek_pos: ClockTime, 37 | pub at_eos: Arc>, 38 | pub metadata: Arc>, 39 | } 40 | 41 | impl PipelineInfo { 42 | pub fn toggle_playing(&self) -> Result<(), GstreamerError> { 43 | match self.pipeline.current_state() { 44 | gstreamer::State::Paused | gstreamer::State::Ready => { 45 | // Restart from the beginning if "play" is pressed at the end of the video 46 | let (position, duration) = ( 47 | self.pipeline.query_position::(), 48 | self.pipeline.query_duration::(), 49 | ); 50 | if let (Some(position), Some(duration)) = (position, duration) { 51 | if position == duration { 52 | self.pipeline.seek_simple( 53 | gstreamer::SeekFlags::FLUSH | gstreamer::SeekFlags::ACCURATE, 54 | ClockTime::ZERO, 55 | )?; 56 | } 57 | } 58 | 59 | self.pipeline.set_state(gstreamer::State::Playing)?; 60 | } 61 | gstreamer::State::Playing => { 62 | self.pipeline.set_state(gstreamer::State::Paused)?; 63 | } 64 | _ => {} 65 | } 66 | 67 | Ok(()) 68 | } 69 | } 70 | 71 | impl Drop for PipelineInfo { 72 | fn drop(&mut self) { 73 | let _ = self.pipeline.set_state(gstreamer::State::Null); 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /crates/gui/src/app/render_settings.rs: -------------------------------------------------------------------------------- 1 | use std::{ops::RangeInclusive, path::PathBuf}; 2 | 3 | use gstreamer::{ClockTime, Fraction}; 4 | use ntscrs::ntsc::{NtscEffect, NtscEffectFullSettings, UseField}; 5 | use serde::{Deserialize, Serialize}; 6 | 7 | #[derive(Debug, Clone, Serialize, Deserialize)] 8 | pub struct H264Settings { 9 | /// Quality (the inverse of the constant rate factor). Although libx264 says it ranges from 0-51, its actual range 10 | /// appears to be 0-50 inclusive. It's flipped from CRF so 50 is lossless and 0 is worst. 11 | pub quality: u8, 12 | /// 0-8 for libx264 presets veryslow-ultrafast 13 | pub encode_speed: u8, 14 | /// Enable 10-bit color 15 | pub ten_bit: bool, 16 | /// Subsample chroma to 4:2:0 17 | pub chroma_subsampling: bool, 18 | } 19 | 20 | impl H264Settings { 21 | pub const QUALITY_RANGE: RangeInclusive = 0..=50; 22 | pub const ENCODE_SPEED_RANGE: RangeInclusive = 0..=8; 23 | } 24 | 25 | impl Default for H264Settings { 26 | fn default() -> Self { 27 | Self { 28 | quality: 27, 29 | encode_speed: 5, 30 | ten_bit: false, 31 | chroma_subsampling: true, 32 | } 33 | } 34 | } 35 | 36 | #[derive(Default, Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] 37 | pub enum Ffv1BitDepth { 38 | #[default] 39 | Bits8, 40 | Bits10, 41 | Bits12, 42 | } 43 | 44 | impl Ffv1BitDepth { 45 | pub fn label(&self) -> &'static str { 46 | match self { 47 | Self::Bits8 => "8-bit", 48 | Self::Bits10 => "10-bit", 49 | Self::Bits12 => "12-bit", 50 | } 51 | } 52 | } 53 | 54 | #[derive(Debug, Clone, Default, Serialize, Deserialize)] 55 | pub struct Ffv1Settings { 56 | pub bit_depth: Ffv1BitDepth, 57 | // Subsample chroma to 4:2:0 58 | pub chroma_subsampling: bool, 59 | } 60 | 61 | #[derive(Debug, Clone, Serialize, Deserialize)] 62 | pub struct PngSequenceSettings { 63 | // TODO: bit depth (requires 16bpc RGB support in GStreamer and reimplementing the PNG encoder plugin) 64 | pub compression_level: u8, 65 | } 66 | 67 | impl PngSequenceSettings { 68 | pub const COMPRESSION_LEVEL_RANGE: RangeInclusive = 0..=9; 69 | } 70 | 71 | impl Default for PngSequenceSettings { 72 | fn default() -> Self { 73 | Self { 74 | compression_level: 6, 75 | } 76 | } 77 | } 78 | 79 | #[derive(Debug, Clone)] 80 | pub struct PngSettings { 81 | pub seek_to: ClockTime, 82 | pub settings: PngSequenceSettings, 83 | } 84 | 85 | #[derive(Default, Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] 86 | pub enum OutputCodec { 87 | #[default] 88 | H264, 89 | Ffv1, 90 | PngSequence, 91 | } 92 | 93 | impl OutputCodec { 94 | pub fn label(&self) -> &'static str { 95 | match self { 96 | Self::H264 => "H.264", 97 | Self::Ffv1 => "FFV1 (Lossless)", 98 | Self::PngSequence => "PNG Sequence", 99 | } 100 | } 101 | 102 | pub fn extension(&self) -> &'static str { 103 | match self { 104 | Self::H264 => "mp4", 105 | Self::Ffv1 => "mkv", 106 | Self::PngSequence => "png", 107 | } 108 | } 109 | 110 | pub fn is_image_sequence(&self) -> bool { 111 | *self == Self::PngSequence 112 | } 113 | } 114 | 115 | #[derive(Debug, Clone)] 116 | pub enum RenderPipelineCodec { 117 | H264(H264Settings), 118 | Ffv1(Ffv1Settings), 119 | Png(PngSettings), 120 | PngSequence(PngSequenceSettings), 121 | } 122 | 123 | impl RenderPipelineCodec { 124 | pub fn is_image_sequence(&self) -> bool { 125 | matches!(&self, Self::Png(_) | Self::PngSequence(_)) 126 | } 127 | } 128 | 129 | #[derive(Default, Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] 130 | pub enum RenderInterlaceMode { 131 | #[default] 132 | Progressive, 133 | TopFieldFirst, 134 | BottomFieldFirst, 135 | } 136 | 137 | impl RenderInterlaceMode { 138 | pub fn from_use_field(use_field: UseField, enable_interlacing: bool) -> Self { 139 | match ( 140 | use_field.interlaced_output_allowed() && enable_interlacing, 141 | use_field, 142 | ) { 143 | (true, UseField::InterleavedUpper) => RenderInterlaceMode::TopFieldFirst, 144 | (true, UseField::InterleavedLower) => RenderInterlaceMode::BottomFieldFirst, 145 | _ => RenderInterlaceMode::Progressive, 146 | } 147 | } 148 | } 149 | 150 | #[derive(Debug, Clone)] 151 | pub struct StillImageSettings { 152 | pub framerate: Fraction, 153 | pub duration: ClockTime, 154 | } 155 | 156 | #[derive(Debug, Clone)] 157 | pub struct RenderPipelineSettings { 158 | pub codec_settings: RenderPipelineCodec, 159 | pub output_path: PathBuf, 160 | pub interlacing: RenderInterlaceMode, 161 | pub effect_settings: NtscEffect, 162 | } 163 | 164 | impl RenderPipelineSettings { 165 | pub fn from_gui_settings( 166 | effect_settings: &NtscEffectFullSettings, 167 | render_settings: &RenderSettings, 168 | ) -> Self { 169 | Self { 170 | codec_settings: render_settings.into(), 171 | output_path: render_settings.output_path.clone(), 172 | interlacing: RenderInterlaceMode::from_use_field( 173 | effect_settings.use_field, 174 | render_settings.interlaced && render_settings.interlaced_output_allowed(), 175 | ), 176 | effect_settings: effect_settings.into(), 177 | } 178 | } 179 | } 180 | 181 | #[derive(Default, Debug, Clone, Serialize, Deserialize)] 182 | pub struct RenderSettings { 183 | pub output_codec: OutputCodec, 184 | // we want to keep these around even if the user changes their mind and selects a different codec, so they don't 185 | // lose the settings if they change back 186 | pub h264_settings: H264Settings, 187 | pub ffv1_settings: Ffv1Settings, 188 | pub png_sequence_settings: PngSequenceSettings, 189 | #[serde(skip)] 190 | pub output_path: PathBuf, 191 | pub duration: ClockTime, 192 | pub interlaced: bool, 193 | } 194 | 195 | impl RenderSettings { 196 | pub fn interlaced_output_allowed(&self) -> bool { 197 | !self.output_codec.is_image_sequence() 198 | } 199 | } 200 | 201 | impl From<&RenderSettings> for RenderPipelineCodec { 202 | fn from(value: &RenderSettings) -> Self { 203 | match value.output_codec { 204 | OutputCodec::H264 => RenderPipelineCodec::H264(value.h264_settings.clone()), 205 | OutputCodec::Ffv1 => RenderPipelineCodec::Ffv1(value.ffv1_settings.clone()), 206 | OutputCodec::PngSequence => { 207 | RenderPipelineCodec::PngSequence(value.png_sequence_settings.clone()) 208 | } 209 | } 210 | } 211 | } 212 | -------------------------------------------------------------------------------- /crates/gui/src/app/system_fonts.rs: -------------------------------------------------------------------------------- 1 | use std::{borrow::Cow, collections::HashSet}; 2 | 3 | use eframe::{ 4 | egui::FontData, 5 | epaint::text::{FontInsert, InsertFontFamily}, 6 | }; 7 | use log::{debug, warn}; 8 | 9 | pub fn system_fallback_fonts() -> impl Iterator { 10 | let mut collection = fontique::Collection::new(fontique::CollectionOptions { 11 | shared: false, 12 | system_fonts: true, 13 | }); 14 | 15 | let mut seen_sources = HashSet::new(); 16 | let mut fonts = Vec::new(); 17 | 18 | let mut load_font = |collection: &mut fontique::Collection, 19 | family_id: fontique::FamilyId, 20 | script: &[u8; 4]| 21 | -> bool { 22 | let Some(family) = collection.family(family_id) else { 23 | return false; 24 | }; 25 | let Some(font) = family.match_font( 26 | fontique::FontWidth::NORMAL, 27 | fontique::FontStyle::Normal, 28 | fontique::FontWeight::NORMAL, 29 | false, 30 | ) else { 31 | return false; 32 | }; 33 | let fontique::SourceKind::Path(path) = font.source().kind() else { 34 | return false; 35 | }; 36 | 37 | // We may get the same font file for multiple scripts 38 | if !seen_sources.insert(font.source().id()) { 39 | debug!( 40 | "Skipping already-loaded font {} from {:?} for {}", 41 | family.name(), 42 | path, 43 | String::from_utf8_lossy(script) 44 | ); 45 | return true; 46 | } 47 | 48 | debug!( 49 | "Loading font {} from {:?} for {}", 50 | family.name(), 51 | path, 52 | String::from_utf8_lossy(script) 53 | ); 54 | let font_data = match std::fs::read(path) { 55 | Ok(font_data) => font_data, 56 | Err(e) => { 57 | warn!("{:?}", e); 58 | return false; 59 | } 60 | }; 61 | 62 | fonts.push(FontInsert { 63 | name: family.name().to_owned(), 64 | data: FontData { 65 | font: Cow::Owned(font_data), 66 | index: font.index(), 67 | tweak: Default::default(), 68 | }, 69 | families: vec![InsertFontFamily { 70 | family: eframe::egui::FontFamily::Proportional, 71 | priority: eframe::epaint::text::FontPriority::Lowest, 72 | }], 73 | }); 74 | true 75 | }; 76 | 77 | let mut load_fonts = |collection: &mut fontique::Collection, script: &[u8; 4]| -> bool { 78 | let family_ids = collection 79 | .fallback_families(fontique::FallbackKey::new(script, None)) 80 | .collect::>(); 81 | let mut any_loaded = false; 82 | for family_id in family_ids { 83 | any_loaded |= load_font(collection, family_id, script); 84 | } 85 | any_loaded 86 | }; 87 | 88 | // Get fallback fonts for CJK. 89 | // We may also want to try loading Arabic and Devanagari fonts, but egui can't shape them properly right now 90 | for script in [b"Hira", b"Kana", b"Hang", b"Hani", b"Hans", b"Hant"] { 91 | load_fonts(&mut collection, script); 92 | } 93 | 94 | fonts.into_iter() 95 | } 96 | -------------------------------------------------------------------------------- /crates/gui/src/app/third_party_licenses_dialog.rs: -------------------------------------------------------------------------------- 1 | use eframe::egui; 2 | 3 | use crate::third_party_licenses::get_third_party_licenses; 4 | 5 | use super::NtscApp; 6 | 7 | impl NtscApp { 8 | pub fn show_third_party_licenses_dialog(&mut self, ctx: &egui::Context) { 9 | egui::Window::new("Third-Party Licenses") 10 | .open(&mut self.third_party_licenses_dialog_open) 11 | .default_width(400.0) 12 | .default_height(400.0) 13 | .show(ctx, |ui| { 14 | egui::ScrollArea::vertical() 15 | .auto_shrink([false, false]) 16 | .show(ui, |ui| { 17 | for (i, license) in get_third_party_licenses().iter().enumerate() { 18 | if i != 0 { 19 | ui.separator(); 20 | } 21 | egui::CollapsingHeader::new(&license.name) 22 | .id_salt(i) 23 | .show(ui, |ui| { 24 | ui.label(&license.text); 25 | }); 26 | ui.indent(i, |ui| { 27 | ui.label("Used by:"); 28 | for used_by in license.used_by.iter() { 29 | ui.add(egui::Hyperlink::from_label_and_url( 30 | format!("{} {}", used_by.name, used_by.version), 31 | &used_by.url, 32 | )); 33 | } 34 | }); 35 | } 36 | }); 37 | }); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /crates/gui/src/app/ui_context.rs: -------------------------------------------------------------------------------- 1 | use eframe::egui; 2 | 3 | pub trait UIContext: Clone + Send + Sync { 4 | fn request_repaint(&self); 5 | fn current_time(&self) -> f64; 6 | } 7 | 8 | impl UIContext for egui::Context { 9 | fn request_repaint(&self) { 10 | self.request_repaint(); 11 | } 12 | 13 | fn current_time(&self) -> f64 { 14 | self.input(|input| input.time) 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /crates/gui/src/bin/ntsc-rs-launcher.rs: -------------------------------------------------------------------------------- 1 | #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] // hide console window on Windows in release 2 | 3 | use std::{env, process}; 4 | 5 | pub fn main() -> Result<(), std::io::Error> { 6 | let mut launcher_path = env::current_exe()?; 7 | launcher_path.pop(); 8 | launcher_path.extend(["bin", "ntsc-rs-standalone.exe"]); 9 | 10 | if let Err(e) = process::Command::new(launcher_path).spawn() { 11 | rfd::MessageDialog::new() 12 | .set_level(rfd::MessageLevel::Error) 13 | .set_title("Could not launch ntsc-rs") 14 | .set_description(format!( 15 | "ntsc-rs could not be launched. This may happen if you move the ntsc-rs launcher \ 16 | out of its folder without copying the \"bin\" and \"lib\" folders along with \ 17 | it.\n\nError message: {}", 18 | e 19 | )) 20 | .show(); 21 | } 22 | 23 | Ok(()) 24 | } 25 | -------------------------------------------------------------------------------- /crates/gui/src/bin/ntsc-rs-standalone.rs: -------------------------------------------------------------------------------- 1 | #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] // hide console window on Windows in release 2 | 3 | use std::error::Error; 4 | 5 | use gui::app::main::run; 6 | 7 | fn main() -> Result<(), Box> { 8 | #[cfg(all(windows, not(debug_assertions)))] 9 | std::panic::set_hook(Box::new(|info| { 10 | let backtrace = std::backtrace::Backtrace::force_capture(); 11 | rfd::MessageDialog::new() 12 | .set_buttons(rfd::MessageButtons::Ok) 13 | .set_level(rfd::MessageLevel::Error) 14 | .set_description(format!("{info}\n\nBacktrace:\n{backtrace}")) 15 | .set_title("Error") 16 | .show(); 17 | 18 | std::process::exit(1); 19 | })); 20 | 21 | run() 22 | } 23 | -------------------------------------------------------------------------------- /crates/gui/src/expression_parser.rs: -------------------------------------------------------------------------------- 1 | //! This is a simple parser for math expressions so that users can enter them into the GUI's slider numeric inputs. 2 | 3 | use logos::{Lexer, Logos}; 4 | use std::mem; 5 | 6 | fn parse_num(lex: &mut Lexer) -> Option { 7 | lex.slice().parse::().ok() 8 | } 9 | 10 | #[derive(Clone, Copy, Logos, Debug, PartialEq)] 11 | #[logos(skip r"[ \t\n\f]+")] 12 | enum Token { 13 | #[regex(r"([0-9]+(\.[0-9]*)?|(\.[0-9]+))(e[+-][0-9]+)?", parse_num)] 14 | Number(f64), 15 | 16 | #[token("+")] 17 | Plus, 18 | 19 | #[token("-")] 20 | Minus, 21 | 22 | #[token("*")] 23 | Multiply, 24 | 25 | #[token("/")] 26 | Divide, 27 | 28 | #[token("**")] 29 | Power, 30 | 31 | #[token("%")] 32 | Percent, 33 | 34 | #[token("(")] 35 | LParen, 36 | 37 | #[token(")")] 38 | RParen, 39 | } 40 | 41 | #[derive(Debug)] 42 | pub struct ParseError { 43 | pub message: String, 44 | } 45 | 46 | impl ParseError { 47 | fn new(message: &str) -> Self { 48 | ParseError { 49 | message: String::from(message), 50 | } 51 | } 52 | } 53 | 54 | struct LexerWrapper<'a> { 55 | lexer: Lexer<'a, Token>, 56 | cur: Option, 57 | next: Option, 58 | } 59 | 60 | impl<'a> LexerWrapper<'a> { 61 | fn new_from_str(string: &'a str) -> Result, ParseError> { 62 | let mut lexer = Token::lexer(string); 63 | let cur = None; 64 | let next = lexer.next(); 65 | if let Some(token) = &next { 66 | if token.is_err() { 67 | return Err(ParseError::new("aaa")); 68 | } 69 | }; 70 | 71 | Ok(LexerWrapper { 72 | lexer, 73 | cur, 74 | next: next.map(|token| token.unwrap()), 75 | }) 76 | } 77 | 78 | fn advance(&mut self) -> Result, ParseError> { 79 | let next = self 80 | .lexer 81 | .next() 82 | .transpose() 83 | .map_err(|_| ParseError::new("No next token"))?; 84 | let old_next = mem::replace(&mut self.next, next); 85 | self.cur = old_next; 86 | Ok(self.next.as_ref()) 87 | } 88 | } 89 | 90 | fn prefix_binding_power(op: &Token) -> usize { 91 | match op { 92 | Token::Plus | Token::Minus => 7, 93 | _ => panic!("not a prefix operator: {op:?}"), 94 | } 95 | } 96 | 97 | fn postfix_binding_power(op: &Token) -> Option { 98 | match op { 99 | Token::Percent => Some(9), 100 | _ => None, 101 | } 102 | } 103 | 104 | fn infix_binding_power(op: &Token) -> Option<(usize, usize)> { 105 | match op { 106 | Token::Plus | Token::Minus => Some((1, 2)), 107 | Token::Multiply | Token::Divide => Some((3, 4)), 108 | Token::Power => Some((6, 5)), 109 | _ => None, 110 | } 111 | } 112 | 113 | fn eval_expr(lexer: &mut LexerWrapper, min_binding_power: usize) -> Result { 114 | let mut lhs = match lexer.cur { 115 | Some(Token::LParen) => { 116 | lexer.advance()?; 117 | let res = eval_expr(lexer, 0)?; 118 | if lexer.cur != Some(Token::RParen) { 119 | return Err(ParseError::new("Expected closing parenthesis")); 120 | } 121 | Ok(res) 122 | } 123 | Some(Token::Number(value)) => { 124 | lexer.advance()?; 125 | Ok(value) 126 | } 127 | Some(Token::Plus) => { 128 | lexer.advance()?; 129 | let inner_value = eval_expr(lexer, prefix_binding_power(&Token::Plus))?; 130 | // unary plus does nothing 131 | Ok(inner_value) 132 | } 133 | Some(Token::Minus) => { 134 | lexer.advance()?; 135 | let inner_value = eval_expr(lexer, prefix_binding_power(&Token::Minus))?; 136 | // unary negation 137 | Ok(-inner_value) 138 | } 139 | _ => Err(ParseError::new("Invalid left-hand side")), 140 | }?; 141 | 142 | loop { 143 | let op = match lexer.cur { 144 | Some(token) => match token { 145 | Token::Plus 146 | | Token::Minus 147 | | Token::Multiply 148 | | Token::Divide 149 | | Token::Power 150 | | Token::Percent => Ok(token), 151 | Token::RParen => break Ok(lhs), 152 | _ => Err(ParseError::new("Expected operator")), 153 | }, 154 | None => break Ok(lhs), 155 | }?; 156 | 157 | if let Some(left_bp) = postfix_binding_power(&op) { 158 | if left_bp < min_binding_power { 159 | break Ok(lhs); 160 | } 161 | lexer.advance()?; 162 | lhs = match op { 163 | Token::Percent => lhs * 0.01, 164 | _ => panic!("unhandled op: {op:?}"), 165 | }; 166 | continue; 167 | } 168 | 169 | if let Some((left_bp, right_bp)) = infix_binding_power(&op) { 170 | if left_bp < min_binding_power { 171 | break Ok(lhs); 172 | } 173 | 174 | lexer.advance()?; 175 | let rhs = eval_expr(lexer, right_bp)?; 176 | 177 | lhs = match op { 178 | Token::Plus => lhs + rhs, 179 | Token::Minus => lhs - rhs, 180 | Token::Multiply => lhs * rhs, 181 | Token::Divide => lhs / rhs, 182 | Token::Power => lhs.powf(rhs), 183 | _ => panic!("unhandled op: {op:?}"), 184 | }; 185 | continue; 186 | } 187 | 188 | break Ok(lhs); 189 | } 190 | } 191 | 192 | pub fn eval_expression_string(string: &str) -> Result { 193 | let mut lexer = LexerWrapper::new_from_str(string)?; 194 | lexer.advance()?; 195 | 196 | eval_expr(&mut lexer, 0) 197 | } 198 | -------------------------------------------------------------------------------- /crates/gui/src/gst_utils/clock_format.rs: -------------------------------------------------------------------------------- 1 | use std::ops::RangeInclusive; 2 | 3 | pub fn clock_time_formatter(value: f64, _: RangeInclusive) -> String { 4 | clock_time_format(value as u64) 5 | } 6 | 7 | pub fn clock_time_format(value: u64) -> String { 8 | let display_duration = gstreamer::ClockTime::from_nseconds(value); 9 | format!("{:.*}", 2, display_duration) 10 | } 11 | 12 | pub fn clock_time_parser(input: &str) -> Option { 13 | let mut out_value: Option = None; 14 | const MULTIPLIERS: &[u64] = &[1_000, 60 * 1_000, 60 * 60 * 1_000]; 15 | input 16 | .rsplit(':') 17 | .enumerate() 18 | .try_for_each(|(index, item)| -> Option<()> { 19 | let multiplier = MULTIPLIERS.get(index)?; 20 | if item.contains('.') { 21 | if index != 0 { 22 | return None; 23 | } 24 | *out_value.get_or_insert(0) += 25 | (item.parse::().ok()? * *multiplier as f64) as u64; 26 | } else { 27 | *out_value.get_or_insert(0) += item.parse::().ok()? * *multiplier; 28 | } 29 | Some(()) 30 | }); 31 | out_value.map(|value| value as f64) 32 | } 33 | -------------------------------------------------------------------------------- /crates/gui/src/gst_utils/egui_sink.rs: -------------------------------------------------------------------------------- 1 | use eframe::egui::{ColorImage, Context, Rect, TextureFilter, TextureOptions}; 2 | use eframe::epaint::{Color32, TextureHandle}; 3 | use gstreamer::{Fraction, prelude::*}; 4 | use gstreamer::{PadTemplate, glib}; 5 | use gstreamer_video::subclass::prelude::*; 6 | use gstreamer_video::video_frame::Readable; 7 | use gstreamer_video::{VideoFrame, VideoFrameExt}; 8 | use ntscrs::yiq_fielding::{self, Rgbx8}; 9 | use std::fmt::Debug; 10 | use std::sync::{Mutex, OnceLock}; 11 | 12 | use super::ntscrs_filter::NtscFilterSettings; 13 | use super::process_gst_frame::process_gst_frame; 14 | 15 | #[derive(Clone, glib::Boxed, Default)] 16 | #[boxed_type(name = "SinkTexture")] 17 | pub struct SinkTexture { 18 | pub handle: Option, 19 | pub pixel_aspect_ratio: Option, 20 | } 21 | 22 | impl SinkTexture { 23 | pub fn new() -> Self { 24 | Self { 25 | handle: None, 26 | pixel_aspect_ratio: None, 27 | } 28 | } 29 | } 30 | 31 | #[derive(Debug, Clone, Copy, PartialEq, glib::Boxed, Default)] 32 | #[boxed_type(name = "VideoPreviewSetting")] 33 | pub enum EffectPreviewSetting { 34 | #[default] 35 | Enabled, 36 | Disabled, 37 | SplitScreen(Rect), 38 | } 39 | 40 | impl Debug for SinkTexture { 41 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 42 | f.debug_struct("SinkTexture") 43 | .field("pixel_aspect_ratio", &self.pixel_aspect_ratio) 44 | .finish() 45 | } 46 | } 47 | 48 | #[derive(Debug, Clone, glib::Boxed, Default)] 49 | #[boxed_type(name = "EguiCtx")] 50 | pub struct EguiCtx(pub Option); 51 | 52 | #[derive(glib::Properties, Default)] 53 | #[properties(wrapper_type = super::elements::EguiSink)] 54 | pub struct EguiSink { 55 | #[property(get, set)] 56 | texture: Mutex, 57 | #[property(get, set)] 58 | ctx: Mutex, 59 | #[property(get, set = Self::set_settings)] 60 | settings: Mutex, 61 | #[property(get, set = Self::set_video_preview_mode)] 62 | preview_mode: Mutex, 63 | 64 | video_info: Mutex>, 65 | last_frame: Mutex< 66 | Option<( 67 | gstreamer_video::VideoFrame, 68 | u64, 69 | )>, 70 | >, 71 | } 72 | 73 | impl EguiSink { 74 | fn set_settings(&self, value: NtscFilterSettings) { 75 | *self.settings.lock().unwrap() = value; 76 | let _ = self.update_texture(); 77 | } 78 | 79 | fn set_video_preview_mode(&self, value: EffectPreviewSetting) { 80 | *self.preview_mode.lock().unwrap() = value; 81 | let _ = self.update_texture(); 82 | } 83 | 84 | fn apply_effect( 85 | &self, 86 | vframe: &VideoFrame, 87 | image: &mut ColorImage, 88 | rect: Option, 89 | ) -> Result<(), gstreamer::FlowError> { 90 | let out_stride = image.width() * 4; 91 | process_gst_frame::( 92 | &vframe.as_video_frame_ref(), 93 | image.as_raw_mut(), 94 | out_stride, 95 | rect, 96 | &self.settings.lock().unwrap().0, 97 | )?; 98 | 99 | Ok(()) 100 | } 101 | 102 | pub fn get_image(&self) -> Result { 103 | let vframe = self.last_frame.lock().unwrap(); 104 | let (vframe, ..) = vframe.as_ref().ok_or(gstreamer::FlowError::Error)?; 105 | 106 | let width = vframe.width() as usize; 107 | let height = vframe.height() as usize; 108 | let mut image = ColorImage::new([width, height], Color32::BLACK); 109 | self.apply_effect(vframe, &mut image, None)?; 110 | Ok(image) 111 | } 112 | 113 | pub fn update_texture(&self) -> Result<(), gstreamer::FlowError> { 114 | let mut tex = self.texture.lock().unwrap(); 115 | let vframe = self.last_frame.lock().unwrap(); 116 | let (vframe, ..) = vframe.as_ref().ok_or(gstreamer::FlowError::Error)?; 117 | tex.pixel_aspect_ratio = Some(vframe.info().par()); 118 | 119 | let width = vframe.width() as usize; 120 | let height = vframe.height() as usize; 121 | let mut image = ColorImage::new([width, height], Color32::BLACK); 122 | 123 | match *self.preview_mode.lock().unwrap() { 124 | EffectPreviewSetting::Enabled => { 125 | self.apply_effect(vframe, &mut image, None)?; 126 | } 127 | EffectPreviewSetting::Disabled => { 128 | // Copy directly to egui image when effect is disabled 129 | let src_buf = vframe.plane_data(0).or(Err(gstreamer::FlowError::Error))?; 130 | image.as_raw_mut().copy_from_slice(src_buf); 131 | } 132 | EffectPreviewSetting::SplitScreen(split) => { 133 | let src_buf = vframe.plane_data(0).or(Err(gstreamer::FlowError::Error))?; 134 | image.as_raw_mut().copy_from_slice(src_buf); 135 | 136 | let rect_to_blit_coord = |coord: f32, dim: usize| { 137 | (coord * dim as f32).round().clamp(0.0, dim as f32) as usize 138 | }; 139 | 140 | let rect = yiq_fielding::Rect::new( 141 | rect_to_blit_coord(split.top(), height), 142 | rect_to_blit_coord(split.left(), width), 143 | rect_to_blit_coord(split.bottom(), height), 144 | rect_to_blit_coord(split.right(), width), 145 | ); 146 | 147 | self.apply_effect(vframe, &mut image, Some(rect))?; 148 | } 149 | } 150 | 151 | let Some(ctx) = &self.ctx.lock().unwrap().0 else { 152 | return Err(gstreamer::FlowError::Error); 153 | }; 154 | 155 | let options = TextureOptions { 156 | magnification: TextureFilter::Nearest, 157 | minification: TextureFilter::Linear, 158 | ..Default::default() 159 | }; 160 | match &mut tex.handle { 161 | Some(handle) => { 162 | handle.set(image, options); 163 | } 164 | None => { 165 | tex.handle = Some(ctx.load_texture("preview", image, options)); 166 | } 167 | } 168 | ctx.request_repaint(); 169 | 170 | Ok(()) 171 | } 172 | } 173 | 174 | #[glib::object_subclass] 175 | impl ObjectSubclass for EguiSink { 176 | const NAME: &'static str = "EguiSink"; 177 | type Type = super::elements::EguiSink; 178 | type ParentType = gstreamer_video::VideoSink; 179 | } 180 | 181 | #[glib::derived_properties] 182 | impl ObjectImpl for EguiSink {} 183 | 184 | impl GstObjectImpl for EguiSink {} 185 | 186 | impl ElementImpl for EguiSink { 187 | fn metadata() -> Option<&'static gstreamer::subclass::ElementMetadata> { 188 | static ELEMENT_METADATA: OnceLock = OnceLock::new(); 189 | Some(ELEMENT_METADATA.get_or_init(|| { 190 | gstreamer::subclass::ElementMetadata::new( 191 | "egui sink", 192 | "Sink/Video", 193 | "Video sink for egui texture", 194 | "valadaptive", 195 | ) 196 | })) 197 | } 198 | 199 | fn pad_templates() -> &'static [gstreamer::PadTemplate] { 200 | static PAD_TEMPLATES: OnceLock> = OnceLock::new(); 201 | PAD_TEMPLATES.get_or_init(|| { 202 | let caps = gstreamer_video::VideoCapsBuilder::new() 203 | .format(gstreamer_video::VideoFormat::Rgbx) 204 | .build(); 205 | let pad_template = gstreamer::PadTemplate::builder( 206 | "sink", 207 | gstreamer::PadDirection::Sink, 208 | gstreamer::PadPresence::Always, 209 | &caps, 210 | ) 211 | .build() 212 | .unwrap(); 213 | 214 | vec![pad_template] 215 | }) 216 | } 217 | } 218 | 219 | impl BaseSinkImpl for EguiSink { 220 | fn set_caps(&self, caps: &gstreamer::Caps) -> Result<(), gstreamer::LoggableError> { 221 | let mut video_info = self.video_info.lock().unwrap(); 222 | *video_info = Some(gstreamer_video::VideoInfo::from_caps(caps)?); 223 | Ok(()) 224 | } 225 | } 226 | 227 | impl VideoSinkImpl for EguiSink { 228 | fn show_frame( 229 | &self, 230 | buffer: &gstreamer::Buffer, 231 | ) -> Result { 232 | let video_info = self.video_info.lock().unwrap(); 233 | let video_info = video_info.as_ref().ok_or(gstreamer::FlowError::Error)?; 234 | 235 | let timestamp = buffer.pts().ok_or(gstreamer::FlowError::Error)?.nseconds(); 236 | let frame_num = (video_info.fps().numer() as u128 * (timestamp + 100) as u128 237 | / video_info.fps().denom() as u128) as u64 238 | / gstreamer::ClockTime::SECOND.nseconds(); 239 | 240 | let mut last_frame = self.last_frame.lock().unwrap(); 241 | let should_rerender = match last_frame.as_ref() { 242 | Some((last, last_frame_num)) => { 243 | *last_frame_num != frame_num || last.buffer() != buffer.as_ref() 244 | } 245 | None => true, 246 | }; 247 | 248 | if should_rerender { 249 | let owned_frame = 250 | gstreamer_video::VideoFrame::from_buffer_readable(buffer.copy(), video_info) 251 | .unwrap(); 252 | *last_frame = Some((owned_frame, frame_num)); 253 | drop(last_frame); 254 | self.update_texture()?; 255 | } 256 | 257 | Ok(gstreamer::FlowSuccess::Ok) 258 | } 259 | } 260 | -------------------------------------------------------------------------------- /crates/gui/src/gst_utils/gstreamer_error.rs: -------------------------------------------------------------------------------- 1 | use std::{error::Error, fmt::Display}; 2 | 3 | use gstreamer::glib; 4 | 5 | #[derive(Debug, Clone)] 6 | pub enum GstreamerError { 7 | GlibError(glib::Error), 8 | BoolError(Box), 9 | PadLinkError(gstreamer::PadLinkError), 10 | StateChangeError(gstreamer::StateChangeError), 11 | FlowError(gstreamer::FlowError), 12 | } 13 | 14 | impl From for GstreamerError { 15 | fn from(value: glib::Error) -> Self { 16 | GstreamerError::GlibError(value) 17 | } 18 | } 19 | 20 | impl From for GstreamerError { 21 | fn from(value: glib::BoolError) -> Self { 22 | GstreamerError::BoolError(Box::new(value)) 23 | } 24 | } 25 | 26 | impl From for GstreamerError { 27 | fn from(value: gstreamer::PadLinkError) -> Self { 28 | GstreamerError::PadLinkError(value) 29 | } 30 | } 31 | 32 | impl From for GstreamerError { 33 | fn from(value: gstreamer::StateChangeError) -> Self { 34 | GstreamerError::StateChangeError(value) 35 | } 36 | } 37 | 38 | impl From for GstreamerError { 39 | fn from(value: gstreamer::FlowError) -> Self { 40 | GstreamerError::FlowError(value) 41 | } 42 | } 43 | 44 | impl Display for GstreamerError { 45 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 46 | match self { 47 | GstreamerError::GlibError(e) => e.fmt(f), 48 | GstreamerError::BoolError(e) => e.fmt(f), 49 | GstreamerError::PadLinkError(e) => e.fmt(f), 50 | GstreamerError::StateChangeError(e) => e.fmt(f), 51 | GstreamerError::FlowError(e) => e.fmt(f), 52 | } 53 | } 54 | } 55 | 56 | impl Error for GstreamerError { 57 | fn source(&self) -> Option<&(dyn Error + 'static)> { 58 | match self { 59 | GstreamerError::GlibError(e) => Some(e), 60 | GstreamerError::BoolError(e) => Some(e), 61 | GstreamerError::PadLinkError(e) => Some(e), 62 | GstreamerError::StateChangeError(e) => Some(e), 63 | GstreamerError::FlowError(e) => Some(e), 64 | } 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /crates/gui/src/gst_utils/init.rs: -------------------------------------------------------------------------------- 1 | use gstreamer::prelude::*; 2 | 3 | use super::{elements, gstreamer_error::GstreamerError}; 4 | 5 | pub fn initialize_gstreamer() -> Result<(), GstreamerError> { 6 | gstreamer::init()?; 7 | 8 | gstreamer::Element::register( 9 | None, 10 | "eguisink", 11 | gstreamer::Rank::NONE, 12 | elements::EguiSink::static_type(), 13 | )?; 14 | 15 | gstreamer::Element::register( 16 | None, 17 | "ntscfilter", 18 | gstreamer::Rank::NONE, 19 | elements::NtscFilter::static_type(), 20 | )?; 21 | 22 | gstreamer::Element::register( 23 | None, 24 | "videopadfilter", 25 | gstreamer::Rank::NONE, 26 | elements::VideoPadFilter::static_type(), 27 | )?; 28 | 29 | // GStreamer's distribution packages currently don't include the webp codecs (either the elements themselves are 30 | // missing or they don't work without libwebp; not sure). Instead, use and statically link gst-plugins-rs/webp. 31 | // Even if webpdec is supported on the platform (e.g. Linux with a package-manager-provided gstreamer), we want to 32 | // always use the Rust webp decoder to avoid weird platform-specific bugs. 33 | if let Some(dec) = gstreamer::ElementFactory::find("webpdec") { 34 | dec.set_rank(gstreamer::Rank::NONE); 35 | } 36 | gstrswebp::plugin_register_static()?; 37 | 38 | // PulseAudio has a severe bug that will greatly delay initial playback to the point of unusability: 39 | // https://gitlab.freedesktop.org/pulseaudio/pulseaudio/-/issues/1383 40 | // A fix was merged a *year* ago, but the Pulse devs, in their infinite wisdom, won't give it to us until their 41 | // next major release, the first RC of which will apparently arrive "soon": 42 | // https://gitlab.freedesktop.org/pulseaudio/pulseaudio/-/issues/3757#note_2038416 43 | // Until then, disable it and pray that someone writes a PipeWire sink so we don't have to deal with any more 44 | // bugs like this 45 | if let Some(sink) = gstreamer::ElementFactory::find("pulsesink") { 46 | sink.set_rank(gstreamer::Rank::NONE); 47 | } 48 | 49 | // nvh264dec is flaky and seemingly creates spurious caps events with memory:CUDAMemory late in the caps negotiation 50 | // progress, causing caps negotiation to fail (see 51 | // https://gitlab.freedesktop.org/gstreamer/gstreamer/-/issues/2644). This doesn't occur all the time and points to 52 | // some sort of race condition. 53 | if let Some(dec) = gstreamer::ElementFactory::find("nvh264dec") { 54 | dec.set_rank(gstreamer::Rank::NONE); 55 | } 56 | 57 | Ok(()) 58 | } 59 | -------------------------------------------------------------------------------- /crates/gui/src/gst_utils/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod clock_format; 2 | pub mod egui_sink; 3 | pub mod gstreamer_error; 4 | pub mod init; 5 | pub mod multi_file_path; 6 | pub mod ntsc_pipeline; 7 | pub mod ntscrs_filter; 8 | pub mod process_gst_frame; 9 | pub mod video_pad_filter; 10 | 11 | pub mod elements { 12 | use super::{egui_sink, ntscrs_filter, video_pad_filter}; 13 | use gstreamer::glib; 14 | glib::wrapper! { 15 | pub struct EguiSink(ObjectSubclass) @extends gstreamer_video::VideoSink, gstreamer_base::BaseSink, gstreamer::Element, gstreamer::Object; 16 | } 17 | 18 | glib::wrapper! { 19 | pub struct NtscFilter(ObjectSubclass) @extends gstreamer_base::BaseTransform, gstreamer::Element, gstreamer::Object; 20 | } 21 | 22 | glib::wrapper! { 23 | pub struct VideoPadFilter(ObjectSubclass) @extends gstreamer_base::BaseTransform, gstreamer::Element, gstreamer::Object; 24 | } 25 | } 26 | 27 | pub fn scale_from_caps(caps: &gstreamer::Caps, scanlines: usize) -> Option<(i32, i32)> { 28 | let caps_structure = caps.structure(0)?; 29 | let src_width = caps_structure.get::("width").ok()?; 30 | let src_height = caps_structure.get::("height").ok()?; 31 | 32 | let scale_factor = scanlines as f32 / src_height as f32; 33 | let dst_width = (src_width as f32 * scale_factor).round() as i32; 34 | 35 | Some((dst_width, scanlines as i32)) 36 | } 37 | -------------------------------------------------------------------------------- /crates/gui/src/gst_utils/multi_file_path.rs: -------------------------------------------------------------------------------- 1 | use std::path::{Path, PathBuf}; 2 | 3 | /// Convert an output file path, with the sequence number specified as a series of "#" placeholders, to a printf-style 4 | /// format string. Handles escaping percent signs and prevents any potential pitfalls from passing a user-supplied 5 | /// string directly to printf (yes, GStreamer actually does this). 6 | pub fn format_path_for_multi_file(path: impl AsRef) -> PathBuf { 7 | let mut sequence_path = Vec::::with_capacity(path.as_ref().as_os_str().len()); 8 | let src_path_bytes = path.as_ref().as_os_str().as_encoded_bytes(); 9 | let mut i = 0; 10 | let mut added_sequence_number = false; 11 | while i < src_path_bytes.len() { 12 | let path_char = src_path_bytes[i]; 13 | // If it's a "%" character, double it 14 | if path_char == b'%' { 15 | sequence_path.push(b'%'); 16 | } 17 | 18 | // We can only use the placeholder once because it's only passed once into printf 19 | if path_char == b'#' && !added_sequence_number { 20 | added_sequence_number = true; 21 | let mut num_digits = 0; 22 | while src_path_bytes[i] == b'#' { 23 | num_digits += 1; 24 | i += 1; 25 | } 26 | sequence_path.extend([b'%', b'0']); 27 | sequence_path.extend(num_digits.to_string().bytes()); 28 | sequence_path.push(b'd'); 29 | } else { 30 | sequence_path.push(src_path_bytes[i]); 31 | i += 1; 32 | } 33 | } 34 | 35 | if !added_sequence_number { 36 | // If no sequence number was specified by the user, insert one before the file extension 37 | let last_ext = sequence_path 38 | .iter() 39 | .rposition(|c| *c == b'.') 40 | .unwrap_or(sequence_path.len()); 41 | sequence_path.splice(last_ext..last_ext, *b"_%d"); 42 | } 43 | 44 | #[cfg(unix)] 45 | { 46 | use std::os::unix::ffi::OsStringExt; 47 | PathBuf::from(std::ffi::OsString::from_vec(sequence_path)) 48 | } 49 | 50 | #[cfg(not(unix))] 51 | PathBuf::from(String::from_utf8(sequence_path).unwrap()) 52 | } 53 | -------------------------------------------------------------------------------- /crates/gui/src/gst_utils/ntscrs_filter.rs: -------------------------------------------------------------------------------- 1 | use std::sync::{OnceLock, RwLock}; 2 | 3 | use gstreamer::glib; 4 | use gstreamer::prelude::{GstParamSpecBuilderExt, ParamSpecBuilderExt, ToValue}; 5 | use gstreamer_video::subclass::prelude::*; 6 | use gstreamer_video::{VideoFormat, VideoFrameExt}; 7 | 8 | use ntscrs::ntsc::NtscEffect; 9 | use ntscrs::yiq_fielding::{Bgrx8, Rgbx8, Xbgr8, Xrgb8, Xrgb16}; 10 | 11 | use super::process_gst_frame::process_gst_frame; 12 | 13 | #[derive(Clone, glib::Boxed, Default)] 14 | #[boxed_type(name = "NtscFilterSettings")] 15 | pub struct NtscFilterSettings(pub NtscEffect); 16 | 17 | #[derive(Default)] 18 | pub struct NtscFilter { 19 | info: RwLock>, 20 | settings: RwLock, 21 | } 22 | 23 | impl NtscFilter {} 24 | 25 | #[glib::object_subclass] 26 | impl ObjectSubclass for NtscFilter { 27 | const NAME: &'static str = "ntscrs"; 28 | type Type = super::elements::NtscFilter; 29 | type ParentType = gstreamer_video::VideoFilter; 30 | } 31 | 32 | impl ObjectImpl for NtscFilter { 33 | fn properties() -> &'static [glib::ParamSpec] { 34 | static PROPERTIES: OnceLock> = OnceLock::new(); 35 | 36 | PROPERTIES.get_or_init(|| { 37 | vec![ 38 | glib::ParamSpecBoxed::builder::("settings") 39 | .nick("Settings") 40 | .blurb("ntsc-rs settings block") 41 | .mutable_playing() 42 | .controllable() 43 | .build(), 44 | ] 45 | }) 46 | } 47 | 48 | fn set_property(&self, _id: usize, value: &glib::Value, pspec: &glib::ParamSpec) { 49 | if pspec.name() != "settings" { 50 | panic!("Incorrect param spec name {}", pspec.name()); 51 | } 52 | 53 | let mut settings = self.settings.write().unwrap(); 54 | let new_settings = value.get().unwrap(); 55 | *settings = new_settings; 56 | } 57 | 58 | fn property(&self, _id: usize, pspec: &glib::ParamSpec) -> glib::Value { 59 | if pspec.name() != "settings" { 60 | panic!("Incorrect param spec name {}", pspec.name()); 61 | } 62 | 63 | let settings = self.settings.read().unwrap(); 64 | settings.to_value() 65 | } 66 | } 67 | 68 | impl GstObjectImpl for NtscFilter {} 69 | 70 | impl ElementImpl for NtscFilter { 71 | fn metadata() -> Option<&'static gstreamer::subclass::ElementMetadata> { 72 | static PROPERTIES: OnceLock = OnceLock::new(); 73 | Some(PROPERTIES.get_or_init(|| { 74 | gstreamer::subclass::ElementMetadata::new( 75 | "NTSC-rs Filter", 76 | "Filter/Effect/Converter/Video", 77 | "Applies an NTSC/VHS effect to video", 78 | "valadaptive", 79 | ) 80 | })) 81 | } 82 | 83 | fn pad_templates() -> &'static [gstreamer::PadTemplate] { 84 | static PAD_TEMPLATES: OnceLock> = OnceLock::new(); 85 | PAD_TEMPLATES.get_or_init(|| { 86 | let caps = gstreamer_video::VideoCapsBuilder::new() 87 | .format_list([ 88 | VideoFormat::Rgbx, 89 | VideoFormat::Rgba, 90 | VideoFormat::Bgrx, 91 | VideoFormat::Bgra, 92 | VideoFormat::Xrgb, 93 | VideoFormat::Xbgr, 94 | VideoFormat::Argb64, 95 | ]) 96 | .build(); 97 | 98 | let src_pad_template = gstreamer::PadTemplate::builder( 99 | "src", 100 | gstreamer::PadDirection::Src, 101 | gstreamer::PadPresence::Always, 102 | &caps, 103 | ) 104 | .build() 105 | .unwrap(); 106 | 107 | let sink_pad_template = gstreamer::PadTemplate::builder( 108 | "sink", 109 | gstreamer::PadDirection::Sink, 110 | gstreamer::PadPresence::Always, 111 | &caps, 112 | ) 113 | .build() 114 | .unwrap(); 115 | 116 | vec![src_pad_template, sink_pad_template] 117 | }) 118 | } 119 | } 120 | 121 | impl BaseTransformImpl for NtscFilter { 122 | const MODE: gstreamer_base::subclass::BaseTransformMode = 123 | gstreamer_base::subclass::BaseTransformMode::NeverInPlace; 124 | const PASSTHROUGH_ON_SAME_CAPS: bool = false; 125 | const TRANSFORM_IP_ON_PASSTHROUGH: bool = false; 126 | } 127 | 128 | impl VideoFilterImpl for NtscFilter { 129 | fn set_info( 130 | &self, 131 | incaps: &gstreamer::Caps, 132 | in_info: &gstreamer_video::VideoInfo, 133 | outcaps: &gstreamer::Caps, 134 | out_info: &gstreamer_video::VideoInfo, 135 | ) -> Result<(), gstreamer::LoggableError> { 136 | let mut info = self.info.write().unwrap(); 137 | *info = Some(in_info.clone()); 138 | self.parent_set_info(incaps, in_info, outcaps, out_info) 139 | } 140 | 141 | fn transform_frame( 142 | &self, 143 | in_frame: &gstreamer_video::VideoFrameRef<&gstreamer::BufferRef>, 144 | out_frame: &mut gstreamer_video::VideoFrameRef<&mut gstreamer::BufferRef>, 145 | ) -> Result { 146 | let settings = self 147 | .settings 148 | .read() 149 | .or(Err(gstreamer::FlowError::Error))? 150 | .clone() 151 | .0; 152 | 153 | let out_stride = out_frame.plane_stride()[0] as usize; 154 | let out_format = out_frame.format(); 155 | let out_data = out_frame 156 | .plane_data_mut(0) 157 | .or(Err(gstreamer::FlowError::Error))?; 158 | 159 | match out_format { 160 | VideoFormat::Rgbx | VideoFormat::Rgba => { 161 | process_gst_frame::(in_frame, out_data, out_stride, None, &settings)?; 162 | } 163 | VideoFormat::Bgrx | VideoFormat::Bgra => { 164 | process_gst_frame::(in_frame, out_data, out_stride, None, &settings)?; 165 | } 166 | VideoFormat::Xrgb | VideoFormat::Argb => { 167 | process_gst_frame::(in_frame, out_data, out_stride, None, &settings)?; 168 | } 169 | VideoFormat::Xbgr | VideoFormat::Abgr => { 170 | process_gst_frame::(in_frame, out_data, out_stride, None, &settings)?; 171 | } 172 | VideoFormat::Argb64 => { 173 | let data_16 = unsafe { out_data.align_to_mut::() }.1; 174 | process_gst_frame::(in_frame, data_16, out_stride, None, &settings)?; 175 | } 176 | _ => Err(gstreamer::FlowError::NotSupported)?, 177 | }; 178 | 179 | Ok(gstreamer::FlowSuccess::Ok) 180 | } 181 | } 182 | -------------------------------------------------------------------------------- /crates/gui/src/gst_utils/process_gst_frame.rs: -------------------------------------------------------------------------------- 1 | use std::convert::identity; 2 | 3 | use gstreamer::{BufferRef, ClockTime, FlowError}; 4 | use gstreamer_video::{VideoFormat, VideoFrameExt, VideoFrameRef, VideoInterlaceMode}; 5 | use ntscrs::{ 6 | settings::standard::NtscEffect, 7 | yiq_fielding::{ 8 | Bgrx8, BlitInfo, DeinterlaceMode, PixelFormat, Rect, Rgbx8, Xbgr8, Xrgb8, Xrgb16, YiqField, 9 | YiqOwned, YiqView, 10 | }, 11 | }; 12 | 13 | fn frame_to_yiq( 14 | in_frame: &VideoFrameRef<&BufferRef>, 15 | field: YiqField, 16 | ) -> Result { 17 | let width = in_frame.width() as usize; 18 | let height = in_frame.height() as usize; 19 | let in_stride = in_frame.plane_stride()[0] as usize; 20 | let in_data = in_frame.plane_data(0).or(Err(FlowError::Error))?; 21 | let in_format = in_frame.format(); 22 | Ok(match in_format { 23 | VideoFormat::Rgbx | VideoFormat::Rgba => { 24 | YiqOwned::from_strided_buffer::(in_data, in_stride, width, height, field) 25 | } 26 | VideoFormat::Bgrx | VideoFormat::Bgra => { 27 | YiqOwned::from_strided_buffer::(in_data, in_stride, width, height, field) 28 | } 29 | VideoFormat::Xrgb | VideoFormat::Argb => { 30 | YiqOwned::from_strided_buffer::(in_data, in_stride, width, height, field) 31 | } 32 | VideoFormat::Xbgr | VideoFormat::Abgr => { 33 | YiqOwned::from_strided_buffer::(in_data, in_stride, width, height, field) 34 | } 35 | 36 | VideoFormat::Argb64 => { 37 | let data_16 = unsafe { in_data.align_to::() }.1; 38 | YiqOwned::from_strided_buffer::(data_16, in_stride, width, height, field) 39 | } 40 | _ => Err(FlowError::NotSupported)?, 41 | }) 42 | } 43 | 44 | pub fn process_gst_frame( 45 | in_frame: &VideoFrameRef<&BufferRef>, 46 | out_frame: &mut [S::DataFormat], 47 | out_stride: usize, 48 | out_rect: Option, 49 | settings: &NtscEffect, 50 | ) -> Result<(), FlowError> { 51 | let info = in_frame.info(); 52 | 53 | let timestamp = in_frame.buffer().pts().ok_or(FlowError::Error)?.nseconds(); 54 | let frame = (info.fps().numer() as u128 * (timestamp + 100) as u128 55 | / info.fps().denom() as u128) as u64 56 | / ClockTime::SECOND.nseconds(); 57 | 58 | let blit_info = out_rect 59 | .map(|rect| { 60 | BlitInfo::new( 61 | rect, 62 | (rect.left, rect.top), 63 | out_stride, 64 | in_frame.height() as usize, 65 | false, 66 | ) 67 | }) 68 | .unwrap_or_else(|| { 69 | BlitInfo::from_full_frame( 70 | in_frame.width() as usize, 71 | in_frame.height() as usize, 72 | out_stride, 73 | ) 74 | }); 75 | 76 | match in_frame.info().interlace_mode() { 77 | VideoInterlaceMode::Progressive => { 78 | let field = settings.use_field.to_yiq_field(frame as usize); 79 | let mut yiq = frame_to_yiq(in_frame, field)?; 80 | let mut view = YiqView::from(&mut yiq); 81 | settings.apply_effect_to_yiq(&mut view, frame as usize, [1.0, 1.0]); 82 | view.write_to_strided_buffer::( 83 | out_frame, 84 | blit_info, 85 | DeinterlaceMode::Bob, 86 | identity, 87 | ); 88 | } 89 | VideoInterlaceMode::Interleaved | VideoInterlaceMode::Mixed => { 90 | let field = match (in_frame.is_tff(), in_frame.is_onefield()) { 91 | (true, true) => YiqField::Upper, 92 | (false, true) => YiqField::Lower, 93 | (true, false) => YiqField::InterleavedUpper, 94 | (false, false) => YiqField::InterleavedLower, 95 | }; 96 | 97 | let mut yiq = frame_to_yiq(in_frame, field)?; 98 | let mut view = YiqView::from(&mut yiq); 99 | settings.apply_effect_to_yiq( 100 | &mut view, 101 | if in_frame.is_onefield() { 102 | frame as usize * 2 103 | } else { 104 | frame as usize 105 | }, 106 | [1.0, 1.0], 107 | ); 108 | view.write_to_strided_buffer::( 109 | out_frame, 110 | blit_info, 111 | DeinterlaceMode::Skip, 112 | identity, 113 | ); 114 | } 115 | _ => Err(FlowError::NotSupported)?, 116 | } 117 | 118 | Ok(()) 119 | } 120 | -------------------------------------------------------------------------------- /crates/gui/src/gst_utils/video_pad_filter.rs: -------------------------------------------------------------------------------- 1 | use std::sync::{OnceLock, RwLock}; 2 | 3 | use gstreamer::glib; 4 | use gstreamer_video::subclass::prelude::*; 5 | use gstreamer_video::{VideoFormat, VideoFrameExt}; 6 | 7 | #[derive(Default)] 8 | pub struct VideoPadFilter { 9 | info: RwLock>, 10 | } 11 | 12 | impl VideoPadFilter {} 13 | 14 | #[glib::object_subclass] 15 | impl ObjectSubclass for VideoPadFilter { 16 | const NAME: &'static str = "ntscrs_video_pad"; 17 | type Type = super::elements::VideoPadFilter; 18 | type ParentType = gstreamer_video::VideoFilter; 19 | } 20 | 21 | impl ObjectImpl for VideoPadFilter { 22 | fn properties() -> &'static [glib::ParamSpec] { 23 | static PROPERTIES: OnceLock> = OnceLock::new(); 24 | 25 | PROPERTIES.get_or_init(Vec::new) 26 | } 27 | } 28 | 29 | impl GstObjectImpl for VideoPadFilter {} 30 | 31 | impl ElementImpl for VideoPadFilter { 32 | fn metadata() -> Option<&'static gstreamer::subclass::ElementMetadata> { 33 | static ELEMENT_METADATA: OnceLock = OnceLock::new(); 34 | 35 | Some(ELEMENT_METADATA.get_or_init(|| { 36 | gstreamer::subclass::ElementMetadata::new( 37 | "Video Pad (for YUV)", 38 | "Filter/Effect/Converter/Video", 39 | "Applies padding to extend a video to even dimensions", 40 | "valadaptive", 41 | ) 42 | })) 43 | } 44 | 45 | fn pad_templates() -> &'static [gstreamer::PadTemplate] { 46 | static PAD_TEMPLATES: OnceLock> = OnceLock::new(); 47 | 48 | PAD_TEMPLATES.get_or_init(|| { 49 | let caps = gstreamer_video::VideoCapsBuilder::new() 50 | .format_list([ 51 | VideoFormat::Rgbx, 52 | VideoFormat::Rgba, 53 | VideoFormat::Bgrx, 54 | VideoFormat::Bgra, 55 | VideoFormat::Xrgb, 56 | VideoFormat::Xbgr, 57 | VideoFormat::Argb64, 58 | ]) 59 | .build(); 60 | 61 | let src_pad_template = gstreamer::PadTemplate::builder( 62 | "src", 63 | gstreamer::PadDirection::Src, 64 | gstreamer::PadPresence::Always, 65 | &caps, 66 | ) 67 | .build() 68 | .unwrap(); 69 | 70 | let sink_pad_template = gstreamer::PadTemplate::builder( 71 | "sink", 72 | gstreamer::PadDirection::Sink, 73 | gstreamer::PadPresence::Always, 74 | &caps, 75 | ) 76 | .build() 77 | .unwrap(); 78 | 79 | vec![src_pad_template, sink_pad_template] 80 | }) 81 | } 82 | } 83 | 84 | impl BaseTransformImpl for VideoPadFilter { 85 | const MODE: gstreamer_base::subclass::BaseTransformMode = 86 | gstreamer_base::subclass::BaseTransformMode::NeverInPlace; 87 | const PASSTHROUGH_ON_SAME_CAPS: bool = false; 88 | const TRANSFORM_IP_ON_PASSTHROUGH: bool = false; 89 | 90 | fn transform_caps( 91 | &self, 92 | direction: gstreamer::PadDirection, 93 | caps: &gstreamer::Caps, 94 | filter: Option<&gstreamer::Caps>, 95 | ) -> Option { 96 | let other_caps = match direction { 97 | gstreamer::PadDirection::Unknown => None, 98 | gstreamer::PadDirection::Src => Some({ 99 | let mut caps = caps.clone(); 100 | for s in caps.make_mut().iter_mut() { 101 | if let Ok(width) = s.value("width").ok()?.get::() { 102 | if width % 2 == 0 { 103 | s.set_value( 104 | "width", 105 | (&gstreamer::IntRange::::new(width - 1, width)).into(), 106 | ); 107 | } 108 | } 109 | 110 | if let Ok(height) = s.value("height").ok()?.get::() { 111 | if height % 2 == 0 { 112 | s.set_value( 113 | "height", 114 | (&gstreamer::IntRange::::new(height - 1, height)).into(), 115 | ); 116 | } 117 | } 118 | } 119 | caps 120 | }), 121 | gstreamer::PadDirection::Sink => Some({ 122 | let mut out_caps = gstreamer::Caps::new_empty(); 123 | 124 | { 125 | let out_caps = out_caps.get_mut().unwrap(); 126 | 127 | for (idx, s) in caps.iter().enumerate() { 128 | let mut s_out = s.to_owned(); 129 | if let Ok(mut width) = s_out.value("width").ok()?.get::() { 130 | width += width % 2; 131 | s_out.set_value("width", (&width).into()); 132 | } 133 | 134 | if let Ok(mut height) = s_out.value("height").ok()?.get::() { 135 | height += height % 2; 136 | s_out.set_value("height", (&height).into()); 137 | } 138 | 139 | out_caps.append_structure(s_out); 140 | out_caps.set_features(idx, caps.features(idx).map(|f| f.to_owned())); 141 | } 142 | } 143 | 144 | out_caps 145 | }), 146 | }?; 147 | 148 | match filter { 149 | Some(filter) => { 150 | Some(filter.intersect_with_mode(&other_caps, gstreamer::CapsIntersectMode::First)) 151 | } 152 | None => Some(other_caps), 153 | } 154 | } 155 | } 156 | 157 | impl VideoFilterImpl for VideoPadFilter { 158 | fn set_info( 159 | &self, 160 | incaps: &gstreamer::Caps, 161 | in_info: &gstreamer_video::VideoInfo, 162 | outcaps: &gstreamer::Caps, 163 | out_info: &gstreamer_video::VideoInfo, 164 | ) -> Result<(), gstreamer::LoggableError> { 165 | let mut info = self.info.write().unwrap(); 166 | *info = Some(in_info.clone()); 167 | self.parent_set_info(incaps, in_info, outcaps, out_info) 168 | } 169 | 170 | fn transform_frame( 171 | &self, 172 | in_frame: &gstreamer_video::VideoFrameRef<&gstreamer::BufferRef>, 173 | out_frame: &mut gstreamer_video::VideoFrameRef<&mut gstreamer::BufferRef>, 174 | ) -> Result { 175 | let in_width = in_frame.width() as usize; 176 | let in_height = in_frame.height() as usize; 177 | let in_stride = in_frame.plane_stride()[0] as usize; 178 | let in_format = in_frame.format(); 179 | let in_data = in_frame 180 | .plane_data(0) 181 | .or(Err(gstreamer::FlowError::Error))?; 182 | 183 | let out_width = out_frame.width() as usize; 184 | let out_height = out_frame.height() as usize; 185 | let out_stride = out_frame.plane_stride()[0] as usize; 186 | let out_format = out_frame.format(); 187 | let out_data = out_frame 188 | .plane_data_mut(0) 189 | .or(Err(gstreamer::FlowError::Error))?; 190 | 191 | if in_format != out_format || out_width < in_width || out_height < in_height { 192 | return Err(gstreamer::FlowError::NotSupported); 193 | } 194 | 195 | let pixel_stride = in_frame.comp_pstride(0) as usize; 196 | 197 | out_data 198 | .chunks_exact_mut(out_stride) 199 | .enumerate() 200 | .for_each(|(row_idx, chunk)| { 201 | let dst_row = &mut chunk[0..(out_width * pixel_stride)]; 202 | let src_idx = row_idx.min(in_height - 1); 203 | let src_row = 204 | &in_data[in_stride * src_idx..in_stride * src_idx + (in_width * pixel_stride)]; 205 | dst_row[0..(in_width * pixel_stride)].copy_from_slice(src_row); 206 | dst_row[(in_width * pixel_stride)..].fill(*src_row.last().unwrap()); 207 | }); 208 | 209 | Ok(gstreamer::FlowSuccess::Ok) 210 | } 211 | } 212 | -------------------------------------------------------------------------------- /crates/gui/src/lib.rs: -------------------------------------------------------------------------------- 1 | #![warn(clippy::print_stdout)] 2 | 3 | pub mod app; 4 | pub mod expression_parser; 5 | pub mod gst_utils; 6 | pub mod third_party_licenses; 7 | pub mod widgets; 8 | -------------------------------------------------------------------------------- /crates/gui/src/third_party_licenses.rs: -------------------------------------------------------------------------------- 1 | use std::{collections::HashMap, sync::OnceLock}; 2 | 3 | use tinyjson::JsonValue; 4 | 5 | static THIRD_PARTY_LICENSES_JSON: &str = include_str!("../about.json"); 6 | pub struct ThirdPartyCrate { 7 | pub name: String, 8 | pub version: String, 9 | pub url: String, 10 | } 11 | pub struct ThirdPartyLicense { 12 | pub name: String, 13 | pub text: String, 14 | pub used_by: Vec, 15 | } 16 | static THIRD_PARTY_LICENSES: OnceLock> = OnceLock::new(); 17 | 18 | pub fn get_third_party_licenses() -> &'static [ThirdPartyLicense] { 19 | THIRD_PARTY_LICENSES.get_or_init(|| { 20 | let mut licenses = Vec::::new(); 21 | let json = THIRD_PARTY_LICENSES_JSON.parse::().unwrap(); 22 | let json = json.get::>().unwrap(); 23 | let json_licenses = json.get("licenses").unwrap().get::>().unwrap(); 24 | for license in json_licenses { 25 | let license = license.get::>().unwrap(); 26 | let used_by = license 27 | .get("used_by") 28 | .unwrap() 29 | .get::>() 30 | .unwrap() 31 | .iter() 32 | .map(|used_by| { 33 | let used_by = used_by.get::>().unwrap(); 34 | let krate = used_by 35 | .get("crate") 36 | .unwrap() 37 | .get::>() 38 | .unwrap(); 39 | let name = krate.get("name").unwrap().get::().unwrap(); 40 | let version = krate 41 | .get("version") 42 | .unwrap() 43 | .get::() 44 | .unwrap() 45 | .clone(); 46 | ThirdPartyCrate { 47 | name: name.clone(), 48 | version, 49 | url: krate 50 | .get("repository") 51 | .and_then(|repo| repo.get::()) 52 | .cloned() 53 | .unwrap_or_else(|| format!("https://crates.io/crates/{name}")), 54 | } 55 | }) 56 | .collect::>(); 57 | 58 | licenses.push(ThirdPartyLicense { 59 | name: license 60 | .get("name") 61 | .unwrap() 62 | .get::() 63 | .unwrap() 64 | .clone(), 65 | text: license 66 | .get("text") 67 | .unwrap() 68 | .get::() 69 | .unwrap() 70 | .clone(), 71 | used_by, 72 | }) 73 | } 74 | licenses 75 | }) 76 | } 77 | -------------------------------------------------------------------------------- /crates/gui/src/widgets/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod render_job; 2 | pub mod splitscreen; 3 | pub mod timeline; 4 | -------------------------------------------------------------------------------- /crates/gui/src/widgets/render_job.rs: -------------------------------------------------------------------------------- 1 | use std::borrow::Cow; 2 | 3 | use eframe::egui::{self, InnerResponse, Response}; 4 | use gstreamer::ClockTime; 5 | 6 | use crate::app::{ 7 | error::ApplicationError, 8 | format_eta::format_eta, 9 | render_job::{RenderJob, RenderJobProgress, RenderJobState}, 10 | }; 11 | 12 | pub struct RenderJobWidget<'a> { 13 | render_job: &'a mut RenderJob, 14 | } 15 | 16 | impl<'a> RenderJobWidget<'a> { 17 | pub fn new(render_job: &'a mut RenderJob) -> Self { 18 | Self { render_job } 19 | } 20 | } 21 | 22 | pub struct RenderJobResponse { 23 | pub response: Response, 24 | pub closed: bool, 25 | pub error: Option, 26 | } 27 | 28 | impl RenderJobWidget<'_> { 29 | pub fn show(self, ui: &mut egui::Ui) -> RenderJobResponse { 30 | let job = self.render_job; 31 | let InnerResponse { 32 | inner: (closed, error), 33 | response, 34 | } = ui.with_layout(egui::Layout::top_down_justified(egui::Align::Min), |ui| { 35 | let mut closed = false; 36 | let mut error = None; 37 | 38 | let fill = ui.style().visuals.faint_bg_color; 39 | egui::Frame::NONE 40 | .fill(fill) 41 | .stroke(ui.style().visuals.window_stroke) 42 | .corner_radius(ui.style().noninteractive().corner_radius) 43 | .inner_margin(ui.style().spacing.window_margin) 44 | .show(ui, |ui| { 45 | let job_state = job.state.lock().unwrap().clone(); 46 | 47 | let RenderJobProgress { 48 | progress, 49 | position: job_position, 50 | duration: job_duration, 51 | estimated_time_remaining, 52 | } = job.update_progress(ui.ctx()); 53 | 54 | ui.horizontal(|ui| { 55 | ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { 56 | closed = ui.button("🗙").clicked(); 57 | 58 | match &job_state { 59 | RenderJobState::Rendering => { 60 | if ui.button("⏸").clicked() { 61 | let current_time = ui.ctx().input(|input| input.time); 62 | error = job.pause_at_time(current_time).err(); 63 | } 64 | } 65 | RenderJobState::Paused => { 66 | if ui.button("▶").clicked() { 67 | let current_time = ui.ctx().input(|input| input.time); 68 | error = job.resume_at_time(current_time).err(); 69 | } 70 | } 71 | _ => {} 72 | } 73 | 74 | ui.with_layout(egui::Layout::left_to_right(egui::Align::Center), |ui| { 75 | ui.add( 76 | egui::Label::new(job.settings.output_path.to_string_lossy()) 77 | .truncate(), 78 | ); 79 | }) 80 | }); 81 | }); 82 | 83 | ui.separator(); 84 | 85 | ui.add(egui::ProgressBar::new(progress as f32).show_percentage()); 86 | if let RenderJobState::Rendering = job_state { 87 | ui.ctx().request_repaint(); 88 | } 89 | 90 | ui.label(match &job_state { 91 | RenderJobState::Waiting => Cow::Borrowed("Waiting..."), 92 | RenderJobState::Rendering => { 93 | if let (Some(position), Some(duration)) = (job_position, job_duration) { 94 | Cow::Owned(format!( 95 | "Rendering... ({:.2} / {:.2})", 96 | position, duration 97 | )) 98 | } else { 99 | Cow::Borrowed("Rendering...") 100 | } 101 | } 102 | RenderJobState::Paused => Cow::Borrowed("Paused"), 103 | // if the job's start_time is missing, it's probably because it never got a chance to update--in that case, just say it took 0 seconds 104 | RenderJobState::Complete { end_time } => Cow::Owned(format!( 105 | "Completed in {:.2}", 106 | ClockTime::from_mseconds( 107 | ((*end_time - job.start_time.unwrap_or(*end_time)) * 1000.0) as u64 108 | ) 109 | )), 110 | RenderJobState::Error(err) => Cow::Owned(format!("Error: {err}")), 111 | }); 112 | 113 | if matches!( 114 | job_state, 115 | RenderJobState::Rendering | RenderJobState::Paused 116 | ) { 117 | if let Some(time_remaining) = estimated_time_remaining { 118 | let mut label = String::from("Time remaining: "); 119 | format_eta( 120 | &mut label, 121 | time_remaining, 122 | [ 123 | [" hour", " hours"], 124 | [" minute", " minutes"], 125 | [" second", " seconds"], 126 | ], 127 | ", ", 128 | ); 129 | ui.label(&label); 130 | } 131 | } 132 | }); 133 | 134 | (closed, error) 135 | }); 136 | 137 | RenderJobResponse { 138 | response, 139 | closed, 140 | error, 141 | } 142 | } 143 | } 144 | -------------------------------------------------------------------------------- /crates/gui/src/widgets/splitscreen.rs: -------------------------------------------------------------------------------- 1 | use eframe::{ 2 | egui::{self, Context, Id, Rect, Sense, Widget, pos2}, 3 | emath::{lerp, remap_clamp}, 4 | }; 5 | 6 | #[derive(Clone, Copy, Debug)] 7 | enum DraggedEdge { 8 | Left, 9 | Right, 10 | Top, 11 | Bottom, 12 | } 13 | 14 | #[derive(Clone, Copy, Debug)] 15 | struct DragState { 16 | dragged_edge: DraggedEdge, 17 | start_rect: Rect, 18 | } 19 | 20 | #[derive(Clone, Copy, Debug, Default)] 21 | struct State { 22 | drag_state: Option, 23 | } 24 | 25 | impl State { 26 | fn load(ctx: &Context, id: Id) -> Option { 27 | ctx.data_mut(|d| d.get_temp(id)) 28 | } 29 | 30 | fn store(self, ctx: &Context, id: Id) { 31 | ctx.data_mut(|d| d.insert_temp(id, self)); 32 | } 33 | } 34 | 35 | pub struct SplitScreen<'a> { 36 | value: &'a mut Rect, 37 | } 38 | 39 | impl<'a> SplitScreen<'a> { 40 | pub fn new(value: &'a mut Rect) -> Self { 41 | Self { value } 42 | } 43 | } 44 | 45 | impl Widget for SplitScreen<'_> { 46 | fn ui(self, ui: &mut egui::Ui) -> egui::Response { 47 | let desired_size = ui.available_size(); 48 | 49 | let grab_radius = ui.style().interaction.resize_grab_radius_side; 50 | let (id, rect) = ui.allocate_space(desired_size); 51 | 52 | let mut state = State::load(ui.ctx(), id).unwrap_or_default(); 53 | 54 | let lerp_rect = Rect::from_min_max( 55 | pos2( 56 | lerp(rect.min.x..=rect.max.x, self.value.left()), 57 | lerp(rect.min.y..=rect.max.y, self.value.top()), 58 | ), 59 | pos2( 60 | lerp(rect.min.x..=rect.max.x, self.value.right()), 61 | lerp(rect.min.y..=rect.max.y, self.value.bottom()), 62 | ), 63 | ); 64 | 65 | let mut response_left = { 66 | let interact_rect = Rect::from_min_max( 67 | egui::pos2(lerp_rect.left() - grab_radius, lerp_rect.top()), 68 | egui::pos2(lerp_rect.left() + grab_radius, lerp_rect.bottom()), 69 | ); 70 | ui.interact(interact_rect, id.with("left"), Sense::drag()) 71 | }; 72 | 73 | let mut response_right = { 74 | let interact_rect = Rect::from_min_max( 75 | egui::pos2(lerp_rect.right() - grab_radius, lerp_rect.top()), 76 | egui::pos2(lerp_rect.right() + grab_radius, lerp_rect.bottom()), 77 | ); 78 | ui.interact(interact_rect, id.with("right"), Sense::drag()) 79 | }; 80 | 81 | let mut response_top = { 82 | let interact_rect = Rect::from_min_max( 83 | egui::pos2(lerp_rect.left(), lerp_rect.top() - grab_radius), 84 | egui::pos2(lerp_rect.right(), lerp_rect.top() + grab_radius), 85 | ); 86 | ui.interact(interact_rect, id.with("top"), Sense::drag()) 87 | }; 88 | 89 | let mut response_bottom = { 90 | let interact_rect = Rect::from_min_max( 91 | egui::pos2(lerp_rect.left(), lerp_rect.bottom() - grab_radius), 92 | egui::pos2(lerp_rect.right(), lerp_rect.bottom() + grab_radius), 93 | ); 94 | ui.interact(interact_rect, id.with("bottom"), Sense::drag()) 95 | }; 96 | 97 | if response_left.hovered() 98 | || response_left.dragged() 99 | || response_right.hovered() 100 | || response_right.dragged() 101 | { 102 | ui.ctx().set_cursor_icon(egui::CursorIcon::ResizeHorizontal); 103 | } else if response_top.hovered() 104 | || response_top.dragged() | response_bottom.hovered() 105 | || response_bottom.dragged() 106 | { 107 | ui.ctx().set_cursor_icon(egui::CursorIcon::ResizeVertical); 108 | } 109 | 110 | let mut set_dragged_edge = |edge| { 111 | if state.drag_state.is_none() { 112 | state.drag_state = Some(DragState { 113 | dragged_edge: edge, 114 | start_rect: *self.value, 115 | }); 116 | } 117 | }; 118 | 119 | if let (false, false, false, false) = ( 120 | response_left.dragged(), 121 | response_right.dragged(), 122 | response_top.dragged(), 123 | response_bottom.dragged(), 124 | ) { 125 | state.drag_state = None; 126 | } else { 127 | response_left 128 | .dragged() 129 | .then(|| set_dragged_edge(DraggedEdge::Left)); 130 | response_right 131 | .dragged() 132 | .then(|| set_dragged_edge(DraggedEdge::Right)); 133 | response_top 134 | .dragged() 135 | .then(|| set_dragged_edge(DraggedEdge::Top)); 136 | response_bottom 137 | .dragged() 138 | .then(|| set_dragged_edge(DraggedEdge::Bottom)); 139 | 140 | match state.drag_state { 141 | Some(DragState { 142 | dragged_edge: DraggedEdge::Right, 143 | start_rect, 144 | }) => { 145 | let pointer_position_2d = response_right.interact_pointer_pos().unwrap(); 146 | let position = pointer_position_2d.x; 147 | let normalized = remap_clamp(position, rect.x_range(), 0.0..=1.0); 148 | if normalized < start_rect.left() { 149 | self.value.set_right(start_rect.left()); 150 | self.value.set_left(normalized); 151 | } else { 152 | self.value.set_right(normalized); 153 | self.value.set_left(start_rect.left()); 154 | } 155 | response_right.mark_changed(); 156 | } 157 | Some(DragState { 158 | dragged_edge: DraggedEdge::Left, 159 | start_rect, 160 | }) => { 161 | let pointer_position_2d = response_left.interact_pointer_pos().unwrap(); 162 | let position = pointer_position_2d.x; 163 | let normalized = remap_clamp(position, rect.x_range(), 0.0..=1.0); 164 | if normalized > start_rect.right() { 165 | self.value.set_left(start_rect.right()); 166 | self.value.set_right(normalized); 167 | } else { 168 | self.value.set_left(normalized); 169 | self.value.set_right(start_rect.right()); 170 | } 171 | response_left.mark_changed(); 172 | } 173 | Some(DragState { 174 | dragged_edge: DraggedEdge::Top, 175 | start_rect, 176 | }) => { 177 | let pointer_position_2d = response_top.interact_pointer_pos().unwrap(); 178 | let position = pointer_position_2d.y; 179 | let normalized = remap_clamp(position, rect.y_range(), 0.0..=1.0); 180 | if normalized > start_rect.bottom() { 181 | self.value.set_top(start_rect.bottom()); 182 | self.value.set_bottom(normalized); 183 | } else { 184 | self.value.set_top(normalized); 185 | self.value.set_bottom(start_rect.bottom()); 186 | } 187 | response_top.mark_changed(); 188 | } 189 | Some(DragState { 190 | dragged_edge: DraggedEdge::Bottom, 191 | start_rect, 192 | }) => { 193 | let pointer_position_2d = response_bottom.interact_pointer_pos().unwrap(); 194 | let position = pointer_position_2d.y; 195 | let normalized = remap_clamp(position, rect.y_range(), 0.0..=1.0); 196 | if normalized < start_rect.top() { 197 | self.value.set_bottom(start_rect.top()); 198 | self.value.set_top(normalized); 199 | } else { 200 | self.value.set_bottom(normalized); 201 | self.value.set_top(start_rect.top()); 202 | } 203 | response_bottom.mark_changed(); 204 | } 205 | None => {} 206 | } 207 | } 208 | 209 | state.store(ui.ctx(), id); 210 | 211 | if ui.is_rect_visible(rect) { 212 | let painter = ui.painter(); 213 | 214 | // Left edge 215 | painter.line_segment( 216 | [ 217 | egui::pos2(lerp_rect.left(), lerp_rect.top()), 218 | egui::pos2(lerp_rect.left(), lerp_rect.bottom()), 219 | ], 220 | ui.style().interact(&response_left).fg_stroke, 221 | ); 222 | // Right edge 223 | painter.line_segment( 224 | [ 225 | egui::pos2(lerp_rect.right(), lerp_rect.top()), 226 | egui::pos2(lerp_rect.right(), lerp_rect.bottom()), 227 | ], 228 | ui.style().interact(&response_right).fg_stroke, 229 | ); 230 | // Top edge 231 | painter.line_segment( 232 | [ 233 | egui::pos2(lerp_rect.left(), lerp_rect.top()), 234 | egui::pos2(lerp_rect.right(), lerp_rect.top()), 235 | ], 236 | ui.style().interact(&response_top).fg_stroke, 237 | ); 238 | // Bottom edge 239 | painter.line_segment( 240 | [ 241 | egui::pos2(lerp_rect.left(), lerp_rect.bottom()), 242 | egui::pos2(lerp_rect.right(), lerp_rect.bottom()), 243 | ], 244 | ui.style().interact(&response_bottom).fg_stroke, 245 | ); 246 | 247 | // Fill with red rectangle in debug mode 248 | #[cfg(debug_assertions)] 249 | if ui.ctx().debug_on_hover() && ui.interact(rect, id, Sense::hover()).hovered() { 250 | painter.rect_filled( 251 | rect, 252 | egui::CornerRadius::ZERO, 253 | egui::Color32::from_rgba_unmultiplied(255, 0, 0, 64), 254 | ); 255 | } 256 | } 257 | 258 | response_right 259 | .union(response_left) 260 | .union(response_top) 261 | .union(response_bottom) 262 | } 263 | } 264 | -------------------------------------------------------------------------------- /crates/gui/todo.md: -------------------------------------------------------------------------------- 1 | - Render-to-file: pause and resume; better errors 2 | - Ensure error coverage 3 | - Show current (rendered) FPS 4 | - Framerate doubler for 25/30fps 5 | - ctrl+scroll to zoom? 6 | - tooltips for all buttons! 7 | - disable 10-bit color if not supported -------------------------------------------------------------------------------- /crates/macros/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "macros" 3 | version = "0.1.0" 4 | edition = "2024" 5 | license = "MIT OR ISC OR Apache-2.0" 6 | repository = "https://github.com/valadaptive/ntsc-rs/tree/main/crates/macros" 7 | 8 | [lib] 9 | proc-macro = true 10 | 11 | [dependencies] 12 | quote = "1.0.27" 13 | proc-macro2 = "1.0.56" 14 | 15 | [dependencies.syn] 16 | version = "2.0.15" 17 | features = ["full", "extra-traits"] -------------------------------------------------------------------------------- /crates/macros/src/lib.rs: -------------------------------------------------------------------------------- 1 | extern crate proc_macro; 2 | 3 | use proc_macro::TokenStream; 4 | use quote::quote; 5 | use syn::{ 6 | AngleBracketedGenericArguments, Attribute, Expr, FieldValue, Fields, GenericArgument, Ident, 7 | ItemStruct, Member, PathArguments, PathSegment, Token, Type, TypePath, parse_macro_input, 8 | parse2, spanned::Spanned, 9 | }; 10 | 11 | fn gen_fields( 12 | original: &ItemStruct, 13 | ) -> ( 14 | Fields, 15 | Vec, 16 | Vec, 17 | Vec, 18 | Vec, 19 | ) { 20 | let mut fields = original.fields.clone(); 21 | let mut from_ref_fields = Vec::::new(); 22 | let mut from_owned_fields = Vec::::new(); 23 | let mut to_ref_fields = Vec::::new(); 24 | let mut to_owned_fields = Vec::::new(); 25 | 26 | for field in fields.iter_mut() { 27 | let name = field.ident.as_ref().unwrap(); 28 | let mut ts_for_ref = quote! {value.#name}; 29 | let mut ts_for_owned = quote! {value.#name}; 30 | let mut ts_to_ref = quote! {value.#name}; 31 | let mut ts_to_owned = quote! {value.#name}; 32 | let mut new_attrs = Vec::::new(); 33 | for attr in &mut field.attrs { 34 | if attr.path().is_ident("settings_block") { 35 | let Type::Path(TypePath { path: p, .. }) = &mut field.ty else { 36 | continue; 37 | }; 38 | 39 | let Some(segment) = p.segments.first_mut() else { 40 | continue; 41 | }; 42 | 43 | let arg_ident: Result = attr.parse_args(); 44 | let is_nested_fullsettings = match arg_ident { 45 | Ok(ident) => ident == "nested", 46 | Err(_) => false, 47 | }; 48 | 49 | let (to_fullsettings_ident, from_fullsettings) = if is_nested_fullsettings { 50 | let &mut PathSegment { 51 | arguments: 52 | PathArguments::AngleBracketed(AngleBracketedGenericArguments { 53 | args: ref mut arguments, 54 | .. 55 | }), 56 | .. 57 | } = segment 58 | else { 59 | panic!("Expected settings_block to be an Option<...>"); 60 | }; 61 | let Some(&mut GenericArgument::Type(Type::Path(TypePath { 62 | ref mut path, .. 63 | }))) = arguments.first_mut() 64 | else { 65 | panic!("Expected settings_block to be an Option<...>"); 66 | }; 67 | let Some(&mut PathSegment { ref mut ident, .. }) = path.segments.first_mut() 68 | else { 69 | panic!("Expected settings_block to be an Option<...>"); 70 | }; 71 | 72 | let fullsettings_ident = 73 | Ident::new(&(ident.to_string() + "FullSettings"), ident.span()); 74 | 75 | let old_ident = ident.clone(); 76 | *ident = fullsettings_ident.clone(); 77 | 78 | ( 79 | quote! {value.#name.as_ref().map(#fullsettings_ident::from)}, 80 | quote! { 81 | if value.#name.enabled { 82 | Some(#old_ident::from(&value.#name.settings)) 83 | } else { 84 | None 85 | } 86 | }, 87 | ) 88 | } else { 89 | (quote! {value.#name}, quote! {Option::from(&value.#name)}) 90 | }; 91 | 92 | segment.ident = Ident::new("SettingsBlock", segment.ident.span()); 93 | ts_for_ref = quote! {SettingsBlock::from(&#to_fullsettings_ident)}; 94 | ts_for_owned = quote! {SettingsBlock::from(#to_fullsettings_ident)}; 95 | ts_to_ref = from_fullsettings.clone(); 96 | ts_to_owned = from_fullsettings.clone(); 97 | } else { 98 | new_attrs.push(attr.clone()); 99 | } 100 | } 101 | field.attrs = new_attrs; 102 | 103 | let expr_for_ref: Expr = parse2(ts_for_ref).unwrap(); 104 | let expr_for_owned: Expr = parse2(ts_for_owned).unwrap(); 105 | let expr_to_ref: Expr = parse2(ts_to_ref).unwrap(); 106 | let expr_to_owned: Expr = parse2(ts_to_owned).unwrap(); 107 | from_ref_fields.push(FieldValue { 108 | attrs: vec![], 109 | member: Member::Named(field.ident.as_ref().unwrap().clone()), 110 | colon_token: Some(Token![:](expr_for_ref.span())), 111 | expr: expr_for_ref, 112 | }); 113 | from_owned_fields.push(FieldValue { 114 | attrs: vec![], 115 | member: Member::Named(field.ident.as_ref().unwrap().clone()), 116 | colon_token: Some(Token![:](expr_for_owned.span())), 117 | expr: expr_for_owned, 118 | }); 119 | to_ref_fields.push(FieldValue { 120 | attrs: vec![], 121 | member: Member::Named(field.ident.as_ref().unwrap().clone()), 122 | colon_token: Some(Token![:](expr_to_ref.span())), 123 | expr: expr_to_ref, 124 | }); 125 | to_owned_fields.push(FieldValue { 126 | attrs: vec![], 127 | member: Member::Named(field.ident.as_ref().unwrap().clone()), 128 | colon_token: Some(Token![:](expr_to_owned.span())), 129 | expr: expr_to_owned, 130 | }); 131 | } 132 | 133 | ( 134 | fields, 135 | from_ref_fields, 136 | from_owned_fields, 137 | to_ref_fields, 138 | to_owned_fields, 139 | ) 140 | } 141 | 142 | fn generate(original: &ItemStruct) -> proc_macro2::TokenStream { 143 | let mut full_struct = original.clone(); 144 | let old_ident = &original.ident; 145 | let new_ident = &Ident::new( 146 | &(original.ident.to_string() + "FullSettings"), 147 | original.ident.span(), 148 | ); 149 | full_struct.ident = new_ident.clone(); 150 | 151 | let (new_fields, from_ref_fields, from_owned_fields, to_ref_fields, to_owned_fields) = 152 | gen_fields(original); 153 | 154 | full_struct.fields = new_fields; 155 | 156 | quote! { 157 | #full_struct 158 | 159 | impl From<&#old_ident> for #new_ident { 160 | fn from(value: &#old_ident) -> Self { 161 | #new_ident { 162 | #(#from_ref_fields),* 163 | } 164 | } 165 | } 166 | 167 | impl From<#old_ident> for #new_ident { 168 | fn from(value: #old_ident) -> Self { 169 | #new_ident { 170 | #(#from_owned_fields),* 171 | } 172 | } 173 | } 174 | 175 | impl From<&#new_ident> for #old_ident { 176 | fn from(value: &#new_ident) -> Self { 177 | #old_ident { 178 | #(#to_ref_fields),* 179 | } 180 | } 181 | } 182 | 183 | impl From<#new_ident> for #old_ident { 184 | fn from(value: #new_ident) -> Self { 185 | #old_ident { 186 | #(#to_owned_fields),* 187 | } 188 | } 189 | } 190 | 191 | impl Default for #new_ident { 192 | fn default() -> Self { 193 | Self::from(#old_ident::default()) 194 | } 195 | } 196 | } 197 | } 198 | 199 | /// Generates a version of this settings block where all Option fields marked as `#[settings_block]` are replaced with 200 | /// versions of those fields that always persist the "Some" values. This is useful for e.g. keeping around the settings' 201 | /// state in UI even if those settings are disabled at the moment. 202 | #[proc_macro_derive(FullSettings, attributes(settings_block))] 203 | pub fn full_settings(item: TokenStream) -> TokenStream { 204 | let item: ItemStruct = parse_macro_input!(item); 205 | 206 | let full_struct = generate(&item); 207 | 208 | let out = quote! { 209 | #[derive(Clone, Debug, PartialEq)] 210 | #full_struct 211 | }; 212 | 213 | out.into() 214 | } 215 | -------------------------------------------------------------------------------- /crates/ntscrs/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "ntscrs" 3 | version = "0.1.2" 4 | edition = "2024" 5 | license = "MIT OR ISC OR Apache-2.0" 6 | repository = "https://github.com/valadaptive/ntsc-rs/tree/main/crates/ntscrs" 7 | 8 | [dependencies] 9 | glam = "0.30.3" 10 | rand = { version = "0.9.0", default-features = false } 11 | rand_xoshiro = "0.7.0" 12 | simdnoise = { git = "https://github.com/valadaptive/rust-simd-noise", rev = "400d9ac" } 13 | num-traits = "0.2" 14 | macros = {path = "../macros"} 15 | siphasher = "1.0.0" 16 | num-derive = "0.4.1" 17 | tinyjson = "2.5.1" 18 | rayon = "1.8.0" 19 | num_cpus = "1.16.0" 20 | sval = { version = "2.14.1", features = ["std"] } 21 | sval_json = { version = "2.14.1", features = ["std"] } 22 | 23 | [dev-dependencies] 24 | criterion = { version = "0.5", features = ["html_reports"] } 25 | image = { version = "0.25.1", default-features = false, features = ["png"] } 26 | 27 | [target.'cfg(not(windows))'.dev-dependencies] 28 | pprof = { version = "0.14.0", features = ["flamegraph", "criterion"] } 29 | 30 | [[bench]] 31 | name = "filter_profile" 32 | harness = false 33 | 34 | [lints] 35 | workspace = true 36 | -------------------------------------------------------------------------------- /crates/ntscrs/benches/balloons.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/valadaptive/ntsc-rs/93e533d2564d86b283ca22f119db34b538a33049/crates/ntscrs/benches/balloons.png -------------------------------------------------------------------------------- /crates/ntscrs/benches/filter_profile.rs: -------------------------------------------------------------------------------- 1 | extern crate criterion; 2 | use criterion::{Criterion, black_box, criterion_group, criterion_main}; 3 | use image::ImageReader; 4 | use ntscrs::{ntsc::NtscEffect, yiq_fielding::Rgb8}; 5 | #[cfg(not(target_os = "windows"))] 6 | use pprof::criterion::{Output, PProfProfiler}; 7 | 8 | fn criterion_benchmark(c: &mut Criterion) { 9 | let img = ImageReader::open("./benches/balloons.png") 10 | .unwrap() 11 | .decode() 12 | .unwrap(); 13 | let img = img.to_rgb8(); 14 | c.bench_function("full effect", |b| { 15 | b.iter(|| { 16 | let img = img.clone(); 17 | let width = img.width() as usize; 18 | let height = img.height() as usize; 19 | let mut buf = img.into_raw(); 20 | NtscEffect::default().apply_effect_to_buffer::( 21 | (width, height), 22 | &mut buf, 23 | 0, 24 | [1.0, 1.0], 25 | ); 26 | black_box(&mut buf); 27 | }) 28 | }); 29 | } 30 | 31 | criterion_group! { 32 | name = benches; 33 | config = { 34 | #[cfg(not(target_os="windows"))] 35 | let config = Criterion::default().with_profiler(PProfProfiler::new(100, Output::Flamegraph(None))); 36 | #[cfg(target_os="windows")] 37 | let config = Criterion::default(); 38 | 39 | config 40 | }; 41 | targets = criterion_benchmark 42 | } 43 | criterion_main!(benches); 44 | -------------------------------------------------------------------------------- /crates/ntscrs/src/lib.rs: -------------------------------------------------------------------------------- 1 | mod f32x4; 2 | mod filter; 3 | pub mod ntsc; 4 | mod random; 5 | pub mod settings; 6 | mod shift; 7 | pub mod thread_pool; 8 | pub mod yiq_fielding; 9 | 10 | #[macro_use] 11 | extern crate num_derive; 12 | 13 | pub use num_traits::cast::FromPrimitive; 14 | -------------------------------------------------------------------------------- /crates/ntscrs/src/random.rs: -------------------------------------------------------------------------------- 1 | use rand::distr::Distribution; 2 | use siphasher::sip::SipHasher; 3 | use std::hash::{Hash, Hasher}; 4 | 5 | pub struct Geometric { 6 | lambda: f64, 7 | } 8 | 9 | impl Geometric { 10 | pub fn new(p: f64) -> Self { 11 | if p <= 0.0 || p > 1.0 { 12 | panic!("Invalid probability: {p}"); 13 | } 14 | 15 | Geometric { 16 | lambda: (1.0 - p).ln(), 17 | } 18 | } 19 | } 20 | 21 | impl Distribution for Geometric { 22 | // We can simulate a geometric distribution by taking the floor of an exponential distribution 23 | // https://en.wikipedia.org/wiki/Geometric_distribution#Related_distributions 24 | fn sample(&self, rng: &mut R) -> usize { 25 | (rng.random::().ln() / self.lambda) as usize 26 | } 27 | } 28 | 29 | pub trait FromSeeder { 30 | fn from_seeder(input: u64) -> Self; 31 | } 32 | 33 | impl FromSeeder for u64 { 34 | #[inline(always)] 35 | fn from_seeder(input: u64) -> Self { 36 | input 37 | } 38 | } 39 | 40 | impl FromSeeder for i32 { 41 | #[inline(always)] 42 | fn from_seeder(input: u64) -> Self { 43 | (input & (u32::MAX as u64)) as i32 44 | } 45 | } 46 | 47 | impl FromSeeder for f64 { 48 | #[inline(always)] 49 | fn from_seeder(input: u64) -> Self { 50 | f64::from_bits((input | 0x3FF0000000000000) & 0x3FFFFFFFFFFFFFFF) - 1.0 51 | } 52 | } 53 | 54 | impl FromSeeder for f32 { 55 | #[inline(always)] 56 | fn from_seeder(input: u64) -> Self { 57 | f32::from_bits(((input & 0xFFFFFFFF) as u32 >> 9) | 0x3F800000) - 1.0 58 | } 59 | } 60 | 61 | /// RNG seed generator which allows you to mix in as much of your own entropy as you want before generating the final 62 | /// seed. 63 | #[derive(Clone)] 64 | pub struct Seeder { 65 | state: SipHasher, 66 | } 67 | 68 | impl Seeder { 69 | pub fn new(seed: T) -> Self { 70 | let mut hasher = SipHasher::new_with_keys(0, 0); 71 | seed.hash(&mut hasher); 72 | Seeder { state: hasher } 73 | } 74 | 75 | pub fn mix(mut self, input: T) -> Self { 76 | input.hash(&mut self.state); 77 | self 78 | } 79 | 80 | pub fn finalize(self) -> T { 81 | T::from_seeder(self.state.finish()) 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /crates/ntscrs/src/settings/mod.rs: -------------------------------------------------------------------------------- 1 | mod settings; 2 | pub use settings::*; 3 | 4 | pub mod easy; 5 | pub mod standard; 6 | -------------------------------------------------------------------------------- /crates/ntscrs/src/shift.rs: -------------------------------------------------------------------------------- 1 | #[derive(Clone, Copy, Debug, PartialEq)] 2 | pub enum BoundaryHandling { 3 | /// Repeat the boundary pixel over and over. 4 | Extend, 5 | /// Use a specific constant for the boundary. 6 | Constant(f32), 7 | } 8 | 9 | fn shift_row_initial_conditions( 10 | row: &[f32], 11 | shift: f32, 12 | boundary_handling: BoundaryHandling, 13 | ) -> (isize, f32, f32, f32) { 14 | // Floor the shift (conversions round towards zero) 15 | let shift_int = shift as isize - if shift < 0.0 { 1 } else { 0 }; 16 | 17 | let width = row.len(); 18 | let boundary_value = match boundary_handling { 19 | BoundaryHandling::Extend => { 20 | if shift_int >= 0 { 21 | row[0] 22 | } else { 23 | row[width - 1] 24 | } 25 | } 26 | BoundaryHandling::Constant(value) => value, 27 | }; 28 | let shift_frac = if shift < 0.0 { 29 | 1.0 - shift.fract().abs() 30 | } else { 31 | shift.fract() 32 | }; 33 | 34 | let prev = if shift_int >= width as isize || shift_int < -(width as isize) { 35 | boundary_value 36 | } else if shift_int >= 0 { 37 | row[(width - shift_int as usize) - 1] 38 | } else { 39 | row[-shift_int as usize - 1] 40 | }; 41 | 42 | (shift_int, shift_frac, boundary_value, prev) 43 | } 44 | 45 | /// Shift a row by a non-integer amount using linear interpolation. 46 | pub fn shift_row(row: &mut [f32], shift: f32, boundary_handling: BoundaryHandling) { 47 | let width = row.len(); 48 | let (shift_int, shift_frac, boundary_value, mut prev) = 49 | shift_row_initial_conditions(row, shift, boundary_handling); 50 | 51 | if shift_int >= 0 { 52 | // Shift forwards; iterate the list backwards 53 | let offset = shift_int as usize + 1; 54 | for i in (0..width).rev() { 55 | let old_value = if i >= offset { 56 | row[i - offset] 57 | } else { 58 | boundary_value 59 | }; 60 | row[i] = (prev * (1.0 - shift_frac)) + (old_value * shift_frac); 61 | prev = old_value; 62 | } 63 | } else { 64 | // Shift backwards; iterate the list forwards 65 | let offset = (-shift_int) as usize; 66 | for i in 0..width { 67 | let old_value = if i + offset < width { 68 | row[i + offset] 69 | } else { 70 | boundary_value 71 | }; 72 | row[i] = (prev * shift_frac) + (old_value * (1.0 - shift_frac)); 73 | prev = old_value; 74 | } 75 | } 76 | } 77 | 78 | /// Shift a row by a non-integer amount using linear interpolation. 79 | pub fn shift_row_to(src: &[f32], dst: &mut [f32], shift: f32, boundary_handling: BoundaryHandling) { 80 | let width = src.len(); 81 | let (shift_int, shift_frac, boundary_value, mut prev) = 82 | shift_row_initial_conditions(src, shift, boundary_handling); 83 | if shift_int >= 0 { 84 | // Shift forwards; iterate the list backwards 85 | let offset = shift_int as usize + 1; 86 | for i in (0..width).rev() { 87 | let old_value = if i >= offset { 88 | src[i - offset] 89 | } else { 90 | boundary_value 91 | }; 92 | dst[i] = (prev * (1.0 - shift_frac)) + (old_value * shift_frac); 93 | prev = old_value; 94 | } 95 | } else { 96 | // Shift backwards; iterate the list forwards 97 | let offset = (-shift_int) as usize; 98 | for i in 0..width { 99 | let old_value = if i + offset < width { 100 | src[i + offset] 101 | } else { 102 | boundary_value 103 | }; 104 | dst[i] = (prev * shift_frac) + (old_value * (1.0 - shift_frac)); 105 | prev = old_value; 106 | } 107 | } 108 | } 109 | 110 | #[cfg(test)] 111 | mod tests { 112 | use super::*; 113 | 114 | const TEST_DATA: &[f32] = &[1.0, 2.5, -0.7, 0.0, 0.0, 2.2, 0.3]; 115 | 116 | fn assert_almost_eq(a: &[f32], b: &[f32]) { 117 | let all_almost_equal = a.iter().zip(b).all(|(a, b)| (a - b).abs() <= 0.01); 118 | assert!(all_almost_equal, "{a:?} is almost equal to {b:?}"); 119 | } 120 | 121 | fn test_case(shift: f32, boundary_handling: BoundaryHandling, expected: &[f32]) { 122 | let mut shifted = TEST_DATA.to_vec(); 123 | shift_row(&mut shifted, shift, boundary_handling); 124 | assert_almost_eq(&shifted, expected); 125 | 126 | let mut shifted = TEST_DATA.to_vec(); 127 | shift_row_to(TEST_DATA, &mut shifted, shift, boundary_handling); 128 | assert_almost_eq(&shifted, expected); 129 | } 130 | 131 | #[test] 132 | fn test_shift_pos_1() { 133 | test_case( 134 | 0.5, 135 | BoundaryHandling::Extend, 136 | &[1.0, 1.75, 0.9, -0.35, 0.0, 1.1, 1.25], 137 | ); 138 | } 139 | 140 | #[test] 141 | fn test_shift_pos_2() { 142 | test_case( 143 | 5.5, 144 | BoundaryHandling::Extend, 145 | &[1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.75], 146 | ); 147 | } 148 | 149 | #[test] 150 | fn test_shift_neg_1() { 151 | test_case( 152 | -0.01, 153 | BoundaryHandling::Extend, 154 | &[1.015, 2.468, -0.693, 0.0, 0.02199998, 2.181, 0.3], 155 | ); 156 | } 157 | 158 | #[test] 159 | fn test_shift_neg_2() { 160 | test_case( 161 | -1.01, 162 | BoundaryHandling::Extend, 163 | &[2.468, -0.693, 0.0, 0.02199998, 2.181, 0.3, 0.3], 164 | ); 165 | } 166 | 167 | #[test] 168 | fn test_shift_neg_full() { 169 | test_case( 170 | -6.0, 171 | BoundaryHandling::Extend, 172 | &[0.3, 0.3, 0.3, 0.3, 0.3, 0.3, 0.3], 173 | ); 174 | } 175 | 176 | #[test] 177 | fn test_shift_neg_full_ext() { 178 | test_case( 179 | -7.0, 180 | BoundaryHandling::Extend, 181 | &[0.3, 0.3, 0.3, 0.3, 0.3, 0.3, 0.3], 182 | ); 183 | } 184 | 185 | #[test] 186 | fn test_shift_pos_full() { 187 | test_case( 188 | 6.0, 189 | BoundaryHandling::Extend, 190 | &[1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0], 191 | ); 192 | } 193 | 194 | #[test] 195 | fn test_shift_pos_full_ext() { 196 | test_case( 197 | 7.0, 198 | BoundaryHandling::Extend, 199 | &[1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0], 200 | ); 201 | } 202 | } 203 | -------------------------------------------------------------------------------- /crates/ntscrs/src/thread_pool.rs: -------------------------------------------------------------------------------- 1 | pub fn with_thread_pool(op: impl (FnOnce() -> T) + Send + Sync) -> T { 2 | #[cfg(not(target_arch = "wasm32"))] 3 | { 4 | use std::sync::OnceLock; 5 | static POOL: OnceLock = OnceLock::new(); 6 | 7 | let pool = POOL.get_or_init(|| { 8 | // On Windows debug builds, the stack overflows with the default stack size 9 | let mut pool = rayon::ThreadPoolBuilder::new().stack_size(2 * 1024 * 1024); 10 | 11 | // Use physical core count instead of logical core count. Hyperthreading seems to be ~20-25% slower, at least on 12 | // a Ryzen 7 7700X. 13 | if std::env::var("RAYON_NUM_THREADS").is_err() { 14 | pool = pool.num_threads(num_cpus::get_physical()); 15 | } 16 | 17 | pool.build().unwrap() 18 | }); 19 | 20 | pool.scope(|_| op()) 21 | } 22 | #[cfg(target_arch = "wasm32")] 23 | { 24 | // wasm-bindgen-rayon doesn't support custom thread pools 25 | // https://github.com/RReverser/wasm-bindgen-rayon/issues/18 26 | op() 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /crates/openfx-plugin/.gitignore: -------------------------------------------------------------------------------- 1 | build/ -------------------------------------------------------------------------------- /crates/openfx-plugin/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "openfx-plugin" 3 | version = "0.1.6" 4 | edition = "2021" 5 | license = "MIT OR ISC OR Apache-2.0" 6 | repository = "https://github.com/valadaptive/ntsc-rs/tree/main/crates/openfx-plugin" 7 | 8 | [dependencies] 9 | allocator-api2 = "0.2.16" 10 | ntscrs = { path = "../ntscrs" } 11 | rfd = "0.15.3" 12 | 13 | [build-dependencies] 14 | bindgen = "0.71" 15 | 16 | [lib] 17 | crate-type = ["cdylib"] 18 | 19 | [lints] 20 | workspace = true 21 | -------------------------------------------------------------------------------- /crates/openfx-plugin/README.md: -------------------------------------------------------------------------------- 1 | # ntsc-rs OpenFX plugin 2 | 3 | ntsc-rs can be used as an OpenFX plugin! The plugin can be found under the "Filter" category. 4 | 5 | A lot of different video software supports OpenFX plugins, including: 6 | - Natron (tested; working) 7 | - DaVinci Resolve / Fusion (tested; working) 8 | - HitFilm (untested) 9 | - Vegas (untested) 10 | - Nuke (untested) 11 | 12 | If your video editing software supports OpenFX but has trouble with ntsc-rs, feel free to open an issue; just remember 13 | to include as much information as possible. 14 | 15 | ## Building 16 | 17 | See [the documentation on the ntsc-rs website](https://ntsc.rs/docs/building-from-source/) for up-to-date information. 18 | 19 | ## Installing 20 | 21 | OpenFX plugins are typically installed to a common folder. Your editing software might look for them somewhere 22 | else--consult its documentation to be sure. 23 | 24 | To install the plugin, copy the `NtscRs.ofx.bundle` folder itself to the [common directory for your 25 | platform](https://openfx.readthedocs.io/en/main/Reference/ofxPackaging.html#installation-location). 26 | 27 | ## Usage Notes 28 | 29 | ### Effect Order and Transforms 30 | 31 | NTSC video itself is quite low-resolution--only 480 lines of vertical resolution. As such, you should apply it to 480p 32 | footage for best results (both for performance reasons and correctness reasons). 33 | 34 | When doing so, you should be aware of your timeline resolution, and whether effects like ntsc-rs are applied before or 35 | after its recipient video clip gets resized to fit the timeline. 36 | 37 | If, for example, you place a 480p clip in a 1080p timeline, and add the ntsc-rs effect to it, things could go one of two 38 | ways, depending on what editing software you use: 39 | 40 | - Your editing software applies the ntsc-rs effect to the 480p clip, and then scales it up to 1080p to fit the timeline. 41 | All is well. 42 | - Your editing software *first* scales the 480p clip up to 1008p, *then* applies ntsc-rs. This will produce sub-par, 43 | possibly unintended results, and ntsc-rs will run much slower because it has to process a 1080p clip. 44 | 45 | In particular, effects applied to a clip in DaVinci Resolve behave the second way. Don't apply the ntsc-rs effect to a 46 | low-resolution clip in a high-resolution timeline! Instead, either create a new timeline that matches your clip's 47 | resolution and apply the effect there, or apply the effect in the Fusion panel, where it will be applied prior to 48 | scaling the clip. 49 | 50 | ### sRGB and Gamma 51 | 52 | OpenFX doesn't specify the color space that effects should operate in. Some editing software (e.g. Natron) performs all 53 | effect processing in linear space, whereas other software (e.g. Resolve) seems to do it in sRGB space. 54 | 55 | ntsc-rs expects its input to be in sRGB space. If it isn't, the output will appear incorrect--dark areas of the image 56 | will appear to "glow" and become oversaturated. 57 | 58 | Long story short: 59 | - If you use ntsc-rs and notice that dark, desaturated areas of the image become brighter and more saturated, while the 60 | rest of the image appears more washed-out, check the "Apply sRGB Gamma" box at the bottom of the effect controls. 61 | - If you use ntsc-rs and notice that dark areas of the image become even darker and blown-out, *un*check the "Apply sRGB 62 | Gamma" box at the bottom of the effect controls. 63 | -------------------------------------------------------------------------------- /crates/openfx-plugin/build.rs: -------------------------------------------------------------------------------- 1 | use std::env; 2 | use std::path::PathBuf; 3 | 4 | fn main() { 5 | // Tell cargo to invalidate the built crate whenever the wrapper changes 6 | println!("cargo:rerun-if-changed=wrapper.h"); 7 | 8 | println!( 9 | "cargo:rustc-env=TARGET={}", 10 | std::env::var("TARGET").unwrap() 11 | ); 12 | 13 | // The bindgen::Builder is the main entry point 14 | // to bindgen, and lets you build up options for 15 | // the resulting bindings. 16 | let bindings = bindgen::Builder::default() 17 | // The input header we would like to generate 18 | // bindings for. 19 | .header("wrapper.h") 20 | .blocklist_function("OfxGetNumberOfPlugins") 21 | .blocklist_function("OfxGetPlugin") 22 | // We wrap the OfxStatus enum in a newtype struct so we can annotate it with #[must_use]. 23 | .blocklist_type("OfxStatus") 24 | .blocklist_var("kOfxStat.+") 25 | // Tell cargo to invalidate the built crate whenever any of the 26 | // included header files changed. 27 | .parse_callbacks(Box::new(bindgen::CargoCallbacks::new())) 28 | .generate_cstr(true) 29 | // Finish the builder and generate the bindings. 30 | .generate() 31 | // Unwrap the Result and panic on failure. 32 | .expect("Unable to generate bindings"); 33 | 34 | // Write the bindings to the $OUT_DIR/bindings.rs file. 35 | let out_path = PathBuf::from(env::var("OUT_DIR").unwrap()); 36 | bindings 37 | .write_to_file(out_path.join("bindings.rs")) 38 | .expect("Couldn't write bindings!"); 39 | } 40 | -------------------------------------------------------------------------------- /crates/openfx-plugin/src/bindings.rs: -------------------------------------------------------------------------------- 1 | #![allow(non_upper_case_globals)] 2 | #![allow(non_camel_case_types)] 3 | #![allow(non_snake_case)] 4 | #![allow(unused)] 5 | #![allow(clippy::all)] 6 | 7 | #[must_use] 8 | #[repr(transparent)] 9 | #[derive(Clone, Copy, PartialEq, Eq)] 10 | pub struct OfxStatus(pub std::ffi::c_int); 11 | 12 | impl OfxStatus { 13 | pub fn ofx_ok(self) -> OfxResult<()> { 14 | if self.0 == 0 { Ok(()) } else { Err(self) } 15 | } 16 | } 17 | 18 | impl std::fmt::Debug for OfxStatus { 19 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 20 | match self.0 { 21 | 0 => f.write_str("kOfxStatOK"), 22 | 1 => f.write_str("kOfxStatFailed"), 23 | 2 => f.write_str("kOfxStatErrFatal"), 24 | 3 => f.write_str("kOfxStatErrUnknown"), 25 | 4 => f.write_str("kOfxStatErrMissingHostFeature"), 26 | 5 => f.write_str("kOfxStatErrUnsupported"), 27 | 6 => f.write_str("kOfxStatErrExists"), 28 | 7 => f.write_str("kOfxStatErrFormat"), 29 | 8 => f.write_str("kOfxStatErrMemory"), 30 | 9 => f.write_str("kOfxStatErrBadHandle"), 31 | 10 => f.write_str("kOfxStatErrBadIndex"), 32 | 11 => f.write_str("kOfxStatErrValue"), 33 | 12 => f.write_str("kOfxStatReplyYes"), 34 | 13 => f.write_str("kOfxStatReplyNo"), 35 | 14 => f.write_str("kOfxStatReplyDefault"), 36 | _ => f.debug_tuple("OfxStatus").field(&self.0).finish(), 37 | } 38 | } 39 | } 40 | 41 | impl From for OfxStatus { 42 | fn from(value: std::ffi::c_int) -> Self { 43 | Self(value) 44 | } 45 | } 46 | 47 | pub type OfxResult = Result; 48 | 49 | // bindgen can't import these 50 | #[allow(dead_code)] 51 | pub mod OfxStat { 52 | use crate::bindings::OfxStatus; 53 | 54 | pub const kOfxStatOK: OfxStatus = OfxStatus(0); 55 | pub const kOfxStatFailed: OfxStatus = OfxStatus(1); 56 | pub const kOfxStatErrFatal: OfxStatus = OfxStatus(2); 57 | pub const kOfxStatErrUnknown: OfxStatus = OfxStatus(3); 58 | pub const kOfxStatErrMissingHostFeature: OfxStatus = OfxStatus(4); 59 | pub const kOfxStatErrUnsupported: OfxStatus = OfxStatus(5); 60 | pub const kOfxStatErrExists: OfxStatus = OfxStatus(6); 61 | pub const kOfxStatErrFormat: OfxStatus = OfxStatus(7); 62 | pub const kOfxStatErrMemory: OfxStatus = OfxStatus(8); 63 | pub const kOfxStatErrBadHandle: OfxStatus = OfxStatus(9); 64 | pub const kOfxStatErrBadIndex: OfxStatus = OfxStatus(10); 65 | pub const kOfxStatErrValue: OfxStatus = OfxStatus(11); 66 | pub const kOfxStatReplyYes: OfxStatus = OfxStatus(12); 67 | pub const kOfxStatReplyNo: OfxStatus = OfxStatus(13); 68 | pub const kOfxStatReplyDefault: OfxStatus = OfxStatus(14); 69 | } 70 | 71 | include!(concat!(env!("OUT_DIR"), "/bindings.rs")); 72 | -------------------------------------------------------------------------------- /crates/openfx-plugin/wrapper.h: -------------------------------------------------------------------------------- 1 | #include 2 | 3 | #include "vendor/openfx/include/ofxCore.h" 4 | #include "vendor/openfx/include/ofxDialog.h" 5 | #include "vendor/openfx/include/ofxDrawSuite.h" 6 | #include "vendor/openfx/include/ofxGPURender.h" 7 | #include "vendor/openfx/include/ofxImageEffect.h" 8 | #include "vendor/openfx/include/ofxInteract.h" 9 | #include "vendor/openfx/include/ofxKeySyms.h" 10 | #include "vendor/openfx/include/ofxMemory.h" 11 | #include "vendor/openfx/include/ofxMessage.h" 12 | #include "vendor/openfx/include/ofxMultiThread.h" 13 | #include "vendor/openfx/include/ofxOld.h" 14 | #include "vendor/openfx/include/ofxOpenGLRender.h" 15 | #include "vendor/openfx/include/ofxParam.h" 16 | #include "vendor/openfx/include/ofxParametricParam.h" 17 | #include "vendor/openfx/include/ofxPixels.h" 18 | #include "vendor/openfx/include/ofxProgress.h" 19 | #include "vendor/openfx/include/ofxProperty.h" 20 | #include "vendor/openfx/include/ofxTimeLine.h" -------------------------------------------------------------------------------- /docs/img/appdemo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/valadaptive/ntsc-rs/93e533d2564d86b283ca22f119db34b538a33049/docs/img/appdemo.png -------------------------------------------------------------------------------- /rustfmt.toml: -------------------------------------------------------------------------------- 1 | style_edition = "2024" 2 | -------------------------------------------------------------------------------- /xtask/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "xtask" 3 | version = "0.1.0" 4 | edition = "2024" 5 | 6 | [dependencies] 7 | current_platform = "0.2.0" 8 | clap = {version = "4.5.4", features = ["cargo", "string"] } 9 | cargo_toml = "0.22.1" 10 | walkdir = "2.5.0" 11 | plist = {version = "1.7.0", default-features = false} 12 | 13 | [[bin]] 14 | name = "xtask" 15 | 16 | [lints] 17 | workspace = true 18 | -------------------------------------------------------------------------------- /xtask/README.md: -------------------------------------------------------------------------------- 1 | This package consists of build scripts written in Rust, used to simplify and automate the process of creating 2 | application/plugin bundles for specific platforms. You can get an overview of which tasks it performs via 3 | `cargo xtask help`, and use `cargo xtask help [subcommand]` to learn more about the options and flags for a specific 4 | task. -------------------------------------------------------------------------------- /xtask/src/bin/xtask.rs: -------------------------------------------------------------------------------- 1 | //! cargo-xtask is a pattern which provides a platform-independent way to run build scripts by writing them in Rust. 2 | //! While many of the build scripts are to some degree platform-specific, there's a lot of shared logic that is nice 3 | //! to be able to reuse between platforms. 4 | //! See https://github.com/matklad/cargo-xtask for more information. 5 | 6 | use std::process; 7 | 8 | use xtask::{build_ofx_plugin, macos_ae_plugin, macos_bundle}; 9 | 10 | fn main() { 11 | let cmd = clap::Command::new("xtask") 12 | .subcommand_required(true) 13 | .subcommand(build_ofx_plugin::command()) 14 | .subcommand(macos_ae_plugin::command()) 15 | .subcommand(macos_bundle::command()); 16 | 17 | let matches = cmd.get_matches(); 18 | 19 | let (task, args) = matches.subcommand().unwrap(); 20 | 21 | match task { 22 | "macos-ae-plugin" => { 23 | macos_ae_plugin::main(args).unwrap(); 24 | } 25 | "macos-bundle" => { 26 | macos_bundle::main(args).unwrap(); 27 | } 28 | "build-ofx-plugin" => { 29 | build_ofx_plugin::main(args).unwrap(); 30 | } 31 | _ => { 32 | println!("Invalid xtask: {task}"); 33 | process::exit(1); 34 | } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /xtask/src/build_ofx_plugin.rs: -------------------------------------------------------------------------------- 1 | //! Builds the OpenFX plugin and bundles it according to the OpenFX specification, optionally taking care of producing 2 | //! a universal binary for macOS. 3 | //! For more information, see https://openfx.readthedocs.io/en/main/Reference/ofxPackaging.html. 4 | 5 | use clap::builder::PathBufValueParser; 6 | 7 | use crate::util::targets::{MACOS_AARCH64, MACOS_X86_64, TARGETS, Target}; 8 | use crate::util::{PathBufExt, StatusExt, workspace_dir}; 9 | 10 | use std::error::Error; 11 | use std::ffi::OsString; 12 | use std::fs; 13 | use std::path::PathBuf; 14 | use std::process::Command; 15 | 16 | pub fn command() -> clap::Command { 17 | clap::Command::new("build-ofx-plugin") 18 | .about( 19 | "Builds and bundles the OpenFX plugin, which is then output to `crates/openfx/build`.", 20 | ) 21 | .arg( 22 | clap::Arg::new("release") 23 | .long("release") 24 | .help("Build the plugin in release mode") 25 | .conflicts_with("debug") 26 | .action(clap::ArgAction::SetTrue), 27 | ) 28 | .arg( 29 | clap::Arg::new("debug") 30 | .long("debug") 31 | .help("Build the plugin in debug mode") 32 | .conflicts_with("release") 33 | .action(clap::ArgAction::SetTrue), 34 | ) 35 | .arg( 36 | clap::Arg::new("target") 37 | .long("target") 38 | .help("Set the target triple to compile for") 39 | .default_value(current_platform::CURRENT_PLATFORM), 40 | ) 41 | .arg( 42 | clap::Arg::new("macos-universal") 43 | .long("macos-universal") 44 | .help("Build a macOS universal library (x86_64 and aarch64)") 45 | .action(clap::ArgAction::SetTrue) 46 | .conflicts_with("target"), 47 | ) 48 | .arg( 49 | clap::Arg::new("destdir") 50 | .long("destdir") 51 | .help("The directory that the OpenFX plugin bundle will be output to") 52 | .value_parser(PathBufValueParser::new()) 53 | .default_value( 54 | workspace_dir() 55 | .plus_iter(["crates", "openfx-plugin", "build"]) 56 | .as_os_str() 57 | .to_owned(), 58 | ), 59 | ) 60 | } 61 | 62 | /// Creates the contents of the Info.plist file for the bundle when building for macOS. 63 | fn get_info_plist() -> plist::Value { 64 | let cargo_toml_path = workspace_dir().plus_iter(["crates", "openfx-plugin", "Cargo.toml"]); 65 | let manifest = cargo_toml::Manifest::from_path(cargo_toml_path).unwrap(); 66 | let version = manifest.package().version(); 67 | 68 | let mut info_plist_contents = plist::dictionary::Dictionary::new(); 69 | info_plist_contents.insert( 70 | "CFBundleInfoDictionaryVersion".to_string(), 71 | plist::Value::from("6.0"), 72 | ); 73 | info_plist_contents.insert( 74 | "CFBundleDevelopmentRegion".to_string(), 75 | plist::Value::from("en"), 76 | ); 77 | info_plist_contents.insert( 78 | "CFBundlePackageType".to_string(), 79 | plist::Value::from("BNDL"), 80 | ); 81 | info_plist_contents.insert( 82 | "CFBundleIdentifier".to_string(), 83 | plist::Value::from("rs.ntsc.openfx"), 84 | ); 85 | info_plist_contents.insert("CFBundleVersion".to_string(), plist::Value::from(version)); 86 | info_plist_contents.insert( 87 | "CFBundleShortVersionString".to_string(), 88 | plist::Value::from(version), 89 | ); 90 | info_plist_contents.insert( 91 | "NSHumanReadableCopyright".to_string(), 92 | plist::Value::from("© 2023-2025 valadaptive"), 93 | ); 94 | info_plist_contents.insert("CFBundleSignature".to_string(), plist::Value::from("????")); 95 | 96 | plist::Value::Dictionary(info_plist_contents) 97 | } 98 | 99 | /// Build the plugin for a given target, in either debug or release mode. This is called once in most cases, but when 100 | /// creating a macOS universal binary, it's called twice--once per architecture. 101 | /// This returns the path to the built library. 102 | fn build_plugin_for_target(target: &Target, release_mode: bool) -> std::io::Result { 103 | println!("Building OpenFX plugin for target {}", target.target_triple); 104 | 105 | let mut cargo_args: Vec<_> = vec![ 106 | String::from("build"), 107 | String::from("--package=openfx-plugin"), 108 | String::from("--lib"), 109 | String::from("--target"), 110 | target.target_triple.to_string(), 111 | ]; 112 | if release_mode { 113 | cargo_args.push(String::from("--release")); 114 | } 115 | Command::new("cargo") 116 | .args(&cargo_args) 117 | .status() 118 | .expect_success()?; 119 | 120 | let target_dir_path = workspace_dir().to_path_buf().plus_iter([ 121 | "target", 122 | target.target_triple, 123 | if cargo_args.contains(&String::from("--release")) { 124 | "release" 125 | } else { 126 | "debug" 127 | }, 128 | ]); 129 | 130 | let mut built_library_path = 131 | target_dir_path.plus(target.library_prefix.to_owned() + "openfx_plugin"); 132 | built_library_path.set_extension(target.library_extension); 133 | 134 | Ok(built_library_path) 135 | } 136 | 137 | pub fn main(args: &clap::ArgMatches) -> Result<(), Box> { 138 | let release_mode = args.get_flag("release"); 139 | 140 | // TODO: remove previous built bundle? 141 | 142 | let (built_library_path, ofx_architecture) = if args.get_flag("macos-universal") { 143 | let x86_64_target = MACOS_X86_64; 144 | let aarch64_target = MACOS_AARCH64; 145 | let x86_64_path = build_plugin_for_target(x86_64_target, release_mode)?; 146 | let aarch64_path = build_plugin_for_target(aarch64_target, release_mode)?; 147 | 148 | let dst_path = std::env::temp_dir().plus(format!( 149 | "ntsc-rs-ofx-{}", 150 | std::time::SystemTime::now() 151 | .duration_since(std::time::UNIX_EPOCH) 152 | .unwrap() 153 | .as_millis() 154 | )); 155 | 156 | // Combine the x86_64 and aarch64 builds into one using `lipo`, and output to the temp file we created 157 | // above. 158 | // TODO: Create the directories beforehand, output into that with lipo, and just rename it afterwards? 159 | Command::new("lipo") 160 | .args(&[ 161 | OsString::from("-create"), 162 | OsString::from("-output"), 163 | dst_path.clone().into(), 164 | x86_64_path.into(), 165 | aarch64_path.into(), 166 | ]) 167 | .status() 168 | .expect_success()?; 169 | 170 | // Both targets should have ofx_architecture: "MacOS" since it's a universal binary. Some platforms have 171 | // different bundle directories depending on the architecture, but as of Apple Silicon, that's not done for 172 | // macOS: 173 | // https://openfx.readthedocs.io/en/main/Reference/ofxPackaging.html#macos-architectures-and-universal-binaries 174 | assert_eq!( 175 | x86_64_target.ofx_architecture, 176 | aarch64_target.ofx_architecture 177 | ); 178 | (dst_path, x86_64_target.ofx_architecture) 179 | } else { 180 | let target_triple = args.get_one::("target").unwrap(); 181 | let target = TARGETS 182 | .iter() 183 | .find(|candidate_target| candidate_target.target_triple == target_triple) 184 | .unwrap_or_else(|| panic!("Your target \"{}\" is not supported", target_triple)); 185 | 186 | ( 187 | build_plugin_for_target(target, release_mode)?, 188 | target.ofx_architecture, 189 | ) 190 | }; 191 | 192 | let output_dir = args.get_one::("destdir").unwrap(); 193 | 194 | let plugin_bundle_path = output_dir.plus_iter(["NtscRs.ofx.bundle", "Contents"]); 195 | let plugin_bin_path = plugin_bundle_path.plus_iter([ofx_architecture, "NtscRs.ofx"]); 196 | let plugin_resources_path = plugin_bundle_path.plus_iter(["Resources"]); 197 | 198 | fs::create_dir_all(plugin_bin_path.parent().unwrap())?; 199 | fs::create_dir_all(&plugin_resources_path)?; 200 | fs::copy(built_library_path, plugin_bin_path)?; 201 | if ofx_architecture == "MacOS" { 202 | get_info_plist().to_file_xml(plugin_bundle_path.plus("Info.plist"))?; 203 | fs::copy( 204 | workspace_dir().plus_iter(["assets", "macos_icon_less_detail.png"]), 205 | plugin_resources_path.plus("wtf.vala.NtscRs.png"), 206 | )?; 207 | } else { 208 | fs::copy( 209 | workspace_dir().plus_iter(["assets", "icon.png"]), 210 | plugin_resources_path.plus("wtf.vala.NtscRs.png"), 211 | )?; 212 | } 213 | 214 | Ok(()) 215 | } 216 | -------------------------------------------------------------------------------- /xtask/src/lib.rs: -------------------------------------------------------------------------------- 1 | pub mod build_ofx_plugin; 2 | pub mod macos_ae_plugin; 3 | pub mod macos_bundle; 4 | pub mod util; 5 | -------------------------------------------------------------------------------- /xtask/src/macos_ae_plugin.rs: -------------------------------------------------------------------------------- 1 | //! Builds and bundles the After Effects plugin for macOS. 2 | //! Adapted from https://github.com/AdrianEddy/after-effects/blob/cbcaf4b/AdobePlugin.just. 3 | 4 | use clap::builder::PathBufValueParser; 5 | 6 | use crate::util::targets::{MACOS_AARCH64, MACOS_X86_64, TARGETS, Target}; 7 | use crate::util::{PathBufExt, StatusExt, workspace_dir}; 8 | 9 | use std::error::Error; 10 | use std::ffi::OsString; 11 | use std::fs; 12 | use std::path::PathBuf; 13 | use std::process::Command; 14 | 15 | pub fn command() -> clap::Command { 16 | clap::Command::new("macos-ae-plugin") 17 | .about( 18 | "Builds and bundles the After Effects plugin for macOS, handling Apple-specific \ 19 | things like creating a universal binary and a bundle.", 20 | ) 21 | .arg( 22 | clap::Arg::new("release") 23 | .long("release") 24 | .help("Build the plugin in release mode") 25 | .conflicts_with("debug") 26 | .action(clap::ArgAction::SetTrue), 27 | ) 28 | .arg( 29 | clap::Arg::new("debug") 30 | .long("debug") 31 | .help("Build the plugin in debug mode") 32 | .conflicts_with("release") 33 | .action(clap::ArgAction::SetTrue), 34 | ) 35 | .arg( 36 | clap::Arg::new("target") 37 | .long("target") 38 | .help("Set the target triple to compile for") 39 | .default_value(current_platform::CURRENT_PLATFORM), 40 | ) 41 | .arg( 42 | clap::Arg::new("macos-universal") 43 | .long("macos-universal") 44 | .help("Build a macOS universal library (x86_64 and aarch64)") 45 | .action(clap::ArgAction::SetTrue) 46 | .conflicts_with("target"), 47 | ) 48 | .arg( 49 | clap::Arg::new("destdir") 50 | .long("destdir") 51 | .help("The directory that the After Effects plugin bundle will be output to") 52 | .value_parser(PathBufValueParser::new()) 53 | .default_value(workspace_dir().plus("build").as_os_str().to_owned()), 54 | ) 55 | } 56 | 57 | /// Build the After Effects plugin for a specific target, returning the paths to 1. the plugin library itself and 58 | /// 2. the Carbon resource file (.rsrc) to include with it in the bundle. 59 | fn build_plugin_for_target( 60 | target: &Target, 61 | release_mode: bool, 62 | ) -> std::io::Result<(PathBuf, PathBuf)> { 63 | println!( 64 | "Building After Effects plugin for target {}", 65 | target.target_triple 66 | ); 67 | 68 | let mut cargo_args: Vec<_> = vec![ 69 | String::from("build"), 70 | String::from("--package=ae-plugin"), 71 | String::from("--target"), 72 | target.target_triple.to_string(), 73 | ]; 74 | if release_mode { 75 | cargo_args.push(String::from("--release")); 76 | } 77 | Command::new("cargo") 78 | .args(&cargo_args) 79 | .status() 80 | .expect_success()?; 81 | 82 | let mut target_dir_path = workspace_dir().to_path_buf(); 83 | target_dir_path.extend(&[ 84 | "target", 85 | target.target_triple, 86 | if cargo_args.contains(&String::from("--release")) { 87 | "release" 88 | } else { 89 | "debug" 90 | }, 91 | ]); 92 | 93 | let mut built_library_path = target_dir_path.clone(); 94 | built_library_path.push(target.library_prefix.to_owned() + "ae_plugin"); 95 | built_library_path.set_extension(target.library_extension); 96 | 97 | let built_rsrc_path = target_dir_path.plus("ae-plugin.rsrc"); 98 | 99 | Ok((built_library_path, built_rsrc_path)) 100 | } 101 | 102 | pub fn main(args: &clap::ArgMatches) -> Result<(), Box> { 103 | let release_mode = args.get_flag("release"); 104 | 105 | let build_dir_path = args.get_one::("destdir").unwrap(); 106 | let plugin_dir_path = build_dir_path.plus("ntsc-rs.plugin"); 107 | 108 | // Clean up the previous build. If there is no previous build, this will fail; that's OK. 109 | let _ = fs::remove_dir_all(&plugin_dir_path); 110 | 111 | let contents_dir_path = plugin_dir_path.plus("Contents"); 112 | fs::create_dir_all(&contents_dir_path)?; 113 | 114 | let macos_dir_path = contents_dir_path.plus("MacOS"); 115 | fs::create_dir_all(&macos_dir_path)?; 116 | 117 | let resources_dir_path = contents_dir_path.plus("Resources"); 118 | fs::create_dir_all(&resources_dir_path)?; 119 | 120 | fs::write(contents_dir_path.plus("PkgInfo"), "eFKTFXTC")?; 121 | 122 | let mut info_plist_contents = plist::dictionary::Dictionary::new(); 123 | info_plist_contents.insert( 124 | "CFBundleIdentifier".to_string(), 125 | plist::Value::from("rs.ntsc.afterfx"), 126 | ); 127 | info_plist_contents.insert( 128 | "CFBundlePackageType".to_string(), 129 | plist::Value::from("eFKT"), 130 | ); 131 | info_plist_contents.insert("CFBundleSignature".to_string(), plist::Value::from("FXTC")); 132 | 133 | plist::Value::Dictionary(info_plist_contents) 134 | .to_file_xml(contents_dir_path.plus("Info.plist"))?; 135 | 136 | let (built_library_path, built_rsrc_path) = if args.get_flag("macos-universal") { 137 | let x86_64_target = MACOS_X86_64; 138 | let aarch64_target = MACOS_AARCH64; 139 | 140 | let (x86_64_lib_path, x86_64_rsrc_path) = 141 | build_plugin_for_target(x86_64_target, release_mode)?; 142 | let (aarch64_lib_path, _) = build_plugin_for_target(aarch64_target, release_mode)?; 143 | 144 | let dst_path = std::env::temp_dir().plus(format!( 145 | "ntsc-rs-ae-{}", 146 | std::time::SystemTime::now() 147 | .duration_since(std::time::UNIX_EPOCH) 148 | .unwrap() 149 | .as_millis() 150 | )); 151 | 152 | // Combine the x86_64 and aarch64 builds into one using `lipo`, and output to the temp file we created 153 | // above. 154 | // TODO: Create the directories beforehand, output into that with lipo, and just rename it afterwards? 155 | Command::new("lipo") 156 | .args(&[ 157 | OsString::from("-create"), 158 | OsString::from("-output"), 159 | dst_path.clone().into(), 160 | x86_64_lib_path.into(), 161 | aarch64_lib_path.into(), 162 | ]) 163 | .status() 164 | .expect_success()?; 165 | 166 | // I hope the .rsrc files are the same between builds--I haven't checked and don't want to compare the contents 167 | // in case they do differ but it's OK--but the Justfile mentioned in the docs at the top use the x86_64 .rsrc. 168 | (dst_path, x86_64_rsrc_path) 169 | } else { 170 | let target_triple = args.get_one::("target").unwrap(); 171 | let target = TARGETS 172 | .iter() 173 | .find(|candidate_target| candidate_target.target_triple == target_triple) 174 | .unwrap_or_else(|| panic!("Your target \"{}\" is not supported", target_triple)); 175 | 176 | build_plugin_for_target(target, release_mode)? 177 | }; 178 | 179 | fs::copy(built_library_path, macos_dir_path.plus("ntsc-rs"))?; 180 | fs::copy(built_rsrc_path, resources_dir_path.plus("ntsc-rs.rsrc"))?; 181 | 182 | Ok(()) 183 | } 184 | -------------------------------------------------------------------------------- /xtask/src/macos_bundle.rs: -------------------------------------------------------------------------------- 1 | //! Builds and bundles the standalone GUI as a macOS .app bundle. 2 | 3 | use crate::util::{PathBufExt, StatusExt, copy_recursive, workspace_dir}; 4 | 5 | use std::error::Error; 6 | use std::ffi::{OsStr, OsString}; 7 | use std::fs; 8 | use std::path::{Path, PathBuf}; 9 | use std::process::Command; 10 | 11 | use clap::builder::PathBufValueParser; 12 | 13 | pub fn command() -> clap::Command { 14 | clap::Command::new("macos-bundle") 15 | .about("Builds the standalone app and creates a macOS application bundle.") 16 | .arg( 17 | clap::Arg::new("release") 18 | .long("release") 19 | .help("Build the software in release mode") 20 | .conflicts_with("debug") 21 | .action(clap::ArgAction::SetTrue), 22 | ) 23 | .arg( 24 | clap::Arg::new("debug") 25 | .long("debug") 26 | .help("Build the software in debug mode") 27 | .conflicts_with("release") 28 | .action(clap::ArgAction::SetTrue), 29 | ) 30 | .arg( 31 | clap::Arg::new("destdir") 32 | .long("destdir") 33 | .help("The directory that the app bundle will be output to") 34 | .value_parser(PathBufValueParser::new()) 35 | .default_value(workspace_dir().plus("build").as_os_str().to_owned()), 36 | ) 37 | } 38 | 39 | /// Build the app for a given target, in either debug or release mode. This is called once in most cases, but when 40 | /// creating a macOS universal binary, it's called twice--once per architecture. 41 | /// This returns the path to the built binary. 42 | fn build_for_target(target: &str, release_mode: bool) -> std::io::Result { 43 | println!("Building application for target {}", target); 44 | 45 | let mut cargo_args: Vec<_> = vec![ 46 | String::from("build"), 47 | String::from("--package=gui"), 48 | String::from("--target"), 49 | target.to_string(), 50 | ]; 51 | if release_mode { 52 | cargo_args.push(String::from("--release")); 53 | } 54 | Command::new("cargo") 55 | .args(&cargo_args) 56 | // When cross-compiling, pkg-config will complain and fail by default. Cross-compilation works just fine, so we 57 | // disable the check. 58 | .env("PKG_CONFIG_ALLOW_CROSS", "1") 59 | .status() 60 | .expect_success()?; 61 | 62 | let mut target_dir_path = workspace_dir().to_path_buf(); 63 | target_dir_path.extend(&[ 64 | "target", 65 | target, 66 | if cargo_args.contains(&String::from("--release")) { 67 | "release" 68 | } else { 69 | "debug" 70 | }, 71 | ]); 72 | 73 | Ok(target_dir_path) 74 | } 75 | 76 | /// Use the `sips` utility built into macOS to resize an image (used for the application icon). 77 | /// See https://ss64.com/mac/sips.html. 78 | fn resize_image( 79 | src_path: impl AsRef, 80 | dst_path: impl AsRef, 81 | size: u32, 82 | ) -> std::io::Result<()> { 83 | let size_str = OsString::from(size.to_string()); 84 | let args = [ 85 | OsString::from("-z"), 86 | size_str.clone(), 87 | size_str.clone(), 88 | OsString::from(src_path.as_ref()), 89 | OsString::from("--out"), 90 | OsString::from(dst_path.as_ref()), 91 | ]; 92 | Command::new("sips").args(args).status().expect_success()?; 93 | Ok(()) 94 | } 95 | 96 | pub fn main(args: &clap::ArgMatches) -> Result<(), Box> { 97 | let release_mode = args.get_flag("release"); 98 | 99 | // Build x86_64 and aarch64 binaries. 100 | // TODO: unlike the other macOS xtasks, this doesn't yet support choosing the targets. 101 | println!("Building binaries..."); 102 | let x86_64_dir = build_for_target("x86_64-apple-darwin", release_mode)?; 103 | let aarch64_dir = build_for_target("aarch64-apple-darwin", release_mode)?; 104 | 105 | // Extract gui version from Cargo.toml. 106 | println!("Getting version for Info.plist and creating bundle directories..."); 107 | let mut cargo_toml_path = workspace_dir().to_path_buf(); 108 | cargo_toml_path.extend(["crates", "gui", "Cargo.toml"]); 109 | let gui_manifest = cargo_toml::Manifest::from_path(cargo_toml_path)?; 110 | let gui_version = gui_manifest.package().version(); 111 | 112 | // Construct Info.plist and bundle structure. 113 | let mut info_plist_contents = plist::dictionary::Dictionary::new(); 114 | info_plist_contents.insert( 115 | "CFBundleInfoDictionaryVersion".to_string(), 116 | plist::Value::from("6.0"), 117 | ); 118 | info_plist_contents.insert( 119 | "CFBundleDevelopmentRegion".to_string(), 120 | plist::Value::from("en"), 121 | ); 122 | info_plist_contents.insert( 123 | "CFBundlePackageType".to_string(), 124 | plist::Value::from("APPL"), 125 | ); 126 | info_plist_contents.insert( 127 | "CFBundleIdentifier".to_string(), 128 | plist::Value::from("rs.ntsc.standalone"), 129 | ); 130 | info_plist_contents.insert( 131 | "CFBundleExecutable".to_string(), 132 | plist::Value::from("ntsc-rs-standalone"), 133 | ); 134 | info_plist_contents.insert( 135 | "CFBundleIconFile".to_string(), 136 | plist::Value::from("icon.icns"), 137 | ); 138 | info_plist_contents.insert( 139 | "CFBundleDisplayName".to_string(), 140 | plist::Value::from("ntsc-rs"), 141 | ); 142 | info_plist_contents.insert("CFBundleName".to_string(), plist::Value::from("ntsc-rs")); 143 | info_plist_contents.insert( 144 | "CFBundleVersion".to_string(), 145 | plist::Value::from(gui_version), 146 | ); 147 | info_plist_contents.insert( 148 | "CFBundleShortVersionString".to_string(), 149 | plist::Value::from(gui_version), 150 | ); 151 | info_plist_contents.insert( 152 | "NSHumanReadableCopyright".to_string(), 153 | plist::Value::from("© 2023-2025 valadaptive"), 154 | ); 155 | info_plist_contents.insert("CFBundleSignature".to_string(), plist::Value::from("????")); 156 | 157 | let build_dir_path = args.get_one::("destdir").unwrap(); 158 | let app_dir_path = build_dir_path.plus("ntsc-rs.app"); 159 | let iconset_dir_path = build_dir_path.plus("ntsc-rs.iconset"); 160 | 161 | // Clean up the previous build. If there is no previous build, this will fail; that's OK. 162 | let _ = fs::remove_dir_all(&app_dir_path); 163 | let _ = fs::remove_dir_all(&iconset_dir_path); 164 | 165 | let contents_dir_path = app_dir_path.plus("Contents"); 166 | fs::create_dir_all(&contents_dir_path)?; 167 | 168 | let macos_dir_path = contents_dir_path.plus("MacOS"); 169 | fs::create_dir_all(&macos_dir_path)?; 170 | 171 | let resources_dir_path = contents_dir_path.plus("Resources"); 172 | fs::create_dir_all(&resources_dir_path)?; 173 | 174 | plist::Value::Dictionary(info_plist_contents) 175 | .to_file_xml(contents_dir_path.plus("Info.plist"))?; 176 | 177 | let app_executables = ["ntsc-rs-standalone", "ntsc-rs-cli"]; 178 | 179 | for binary_name in app_executables { 180 | println!("Creating universal binary ({binary_name})..."); 181 | // Combine x86_64 and aarch64 binaries and place the result in the bundle. 182 | Command::new("lipo") 183 | .args(&[ 184 | OsString::from("-create"), 185 | OsString::from("-output"), 186 | macos_dir_path.plus(binary_name).into(), 187 | x86_64_dir.plus(binary_name).into(), 188 | aarch64_dir.plus(binary_name).into(), 189 | ]) 190 | .status() 191 | .expect_success()?; 192 | } 193 | 194 | // Copy gstreamer libraries into the bundle. 195 | println!("Copying gstreamer libraries..."); 196 | let src_gst_path = PathBuf::from("/Library/Frameworks/GStreamer.framework/Versions/1.0"); 197 | let dst_gst_path = 198 | contents_dir_path.plus_iter(["Frameworks", "GStreamer.framework", "Versions", "1.0"]); 199 | let src_lib_path = src_gst_path.plus("lib"); 200 | let dst_lib_path = dst_gst_path.plus("lib"); 201 | // We only want dylibs, not the static libs also present. 202 | copy_recursive(&src_lib_path, &dst_lib_path, |entry| { 203 | entry.path().extension() == Some(OsStr::new("dylib")) 204 | })?; 205 | 206 | let src_libexec_path = src_gst_path.plus("libexec"); 207 | let dst_libexec_path = dst_gst_path.plus("libexec"); 208 | copy_recursive(&src_libexec_path, &dst_libexec_path, |_| true)?; 209 | 210 | // Add gstreamer rpath to the executable, so it can load the gstreamer libraries. According to 211 | // https://gstreamer.freedesktop.org/documentation/deploying/mac-osx.html?gi-language=c#location-of-dependent-dynamic-libraries, 212 | // macOS doesn't locate libraries relative to the executable. That page's prescribed solution is a convoluted 213 | // `osxrelocator.py` script that I've seen several versions of floating around, but I just use `install_name_tool` 214 | // and it *seems* to work fine. GStreamer includes many binaries which the page says also need to be 215 | // `install_name_tool`'d but it seems they now perform that step themselves. They also mention that you need to set 216 | // some environment variables to pick up on binaries also distributed with GStreamer, but they have seemingly made 217 | // those paths relative on their end too. 218 | for binary_name in app_executables { 219 | println!("Adding gstreamer rpath ({binary_name})..."); 220 | Command::new("install_name_tool") 221 | .args([ 222 | OsString::from("-add_rpath"), 223 | OsString::from( 224 | "@executable_path/../Frameworks/GStreamer.framework/Versions/1.0/lib", 225 | ), 226 | OsString::from(macos_dir_path.plus(binary_name)), 227 | ]) 228 | .status() 229 | .expect_success()?; 230 | } 231 | 232 | // Create the iconset. Adapted from https://stackoverflow.com/a/20703594. 233 | 234 | // First, we resize the icons to all the sizes that Apple specifies: 235 | // https://developer.apple.com/design/human-interface-guidelines/app-icons#macOS-app-icon-sizes 236 | // Note that we actually have 2 icons: one for larger sizes, and one for smaller sizes where the thin lines on the 237 | // VHS label are removed. 238 | println!("Resizing icons..."); 239 | let src_icon_folder_path = workspace_dir().plus("assets"); 240 | let icon_lg_path = src_icon_folder_path.plus("macos_icon.png"); 241 | let icon_sm_path = src_icon_folder_path.plus("macos_icon_less_detail.png"); 242 | 243 | fs::create_dir_all(&iconset_dir_path)?; 244 | 245 | resize_image(&icon_sm_path, iconset_dir_path.plus("icon_16x16.png"), 16)?; 246 | let icon_32_path = iconset_dir_path.plus("icon_32x32.png"); 247 | resize_image(&icon_sm_path, &icon_32_path, 32)?; 248 | fs::copy(&icon_32_path, iconset_dir_path.plus("icon_16x16@2x.png"))?; 249 | 250 | resize_image( 251 | &icon_sm_path, 252 | iconset_dir_path.plus("icon_128x128.png"), 253 | 128, 254 | )?; 255 | let icon_256_path = iconset_dir_path.plus("icon_256x256.png"); 256 | resize_image(&icon_lg_path, &icon_256_path, 256)?; 257 | fs::copy(&icon_256_path, iconset_dir_path.plus("icon_128x128@2x.png"))?; 258 | 259 | let icon_512_path = iconset_dir_path.plus("icon_512x512.png"); 260 | resize_image(&icon_lg_path, &icon_512_path, 512)?; 261 | fs::copy(&icon_512_path, iconset_dir_path.plus("icon_256x256@2x.png"))?; 262 | 263 | resize_image( 264 | &icon_lg_path, 265 | iconset_dir_path.plus("icon_512x512@2x.png"), 266 | 1024, 267 | )?; 268 | 269 | // Combine the iconset files into a single .icns. 270 | println!("Creating iconset..."); 271 | Command::new("iconutil") 272 | .args([ 273 | OsString::from("-c"), 274 | OsString::from("icns"), 275 | OsString::from("-o"), 276 | OsString::from(resources_dir_path.plus("icon.icns")), 277 | OsString::from(iconset_dir_path), 278 | ]) 279 | .status() 280 | .expect_success()?; 281 | 282 | // TODO: code signing and notarization 283 | 284 | println!("Done!"); 285 | 286 | Ok(()) 287 | } 288 | -------------------------------------------------------------------------------- /xtask/src/util/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod targets; 2 | 3 | use std::{ 4 | collections::HashSet, 5 | path::{Path, PathBuf}, 6 | process::ExitStatus, 7 | sync::OnceLock, 8 | }; 9 | 10 | use walkdir::{DirEntry, WalkDir}; 11 | 12 | static WORKSPACE_DIR: OnceLock = OnceLock::new(); 13 | 14 | /// Return the path to the root Cargo workspace, even if we're in a subcrate. 15 | pub fn workspace_dir() -> &'static Path { 16 | WORKSPACE_DIR.get_or_init(|| { 17 | let output = std::process::Command::new(env!("CARGO")) 18 | .arg("locate-project") 19 | .arg("--workspace") 20 | .arg("--message-format=plain") 21 | .output() 22 | .unwrap() 23 | .stdout; 24 | let cargo_path = Path::new(std::str::from_utf8(&output).unwrap().trim()); 25 | cargo_path.parent().unwrap().to_path_buf() 26 | }) 27 | } 28 | 29 | pub trait PathBufExt { 30 | /// Chainably append another segment to the given path, returning the result as a new path. 31 | fn plus>(&self, additional: T) -> PathBuf; 32 | /// Chainably append many segments to the given path, returning the result as a new path. 33 | fn plus_iter, I: IntoIterator>(&self, additional: I) -> PathBuf; 34 | } 35 | 36 | impl> PathBufExt for P { 37 | fn plus>(&self, additional: T) -> PathBuf { 38 | let mut new_path = self.as_ref().to_path_buf(); 39 | new_path.push(additional); 40 | new_path 41 | } 42 | 43 | fn plus_iter, I: IntoIterator>(&self, additional: I) -> PathBuf { 44 | let mut new_path = self.as_ref().to_path_buf(); 45 | new_path.extend(additional); 46 | new_path 47 | } 48 | } 49 | 50 | pub trait StatusExt { 51 | /// Converts a non-zero exit status when running a command into an error. 52 | /// In lieu of https://github.com/rust-lang/rfcs/pull/3362. 53 | fn expect_success(self) -> std::io::Result<()>; 54 | } 55 | 56 | impl StatusExt for std::io::Result { 57 | fn expect_success(self) -> std::io::Result<()> { 58 | match self { 59 | Err(e) => Err(e), 60 | Ok(status) => { 61 | if status.success() { 62 | Ok(()) 63 | } else { 64 | Err(std::io::Error::new( 65 | std::io::ErrorKind::Other, 66 | status.to_string(), 67 | )) 68 | } 69 | } 70 | } 71 | } 72 | } 73 | 74 | pub fn copy_recursive( 75 | from: impl AsRef, 76 | to: impl AsRef, 77 | mut predicate: impl FnMut(&DirEntry) -> bool, 78 | ) -> Result<(), Box> { 79 | let mut created_dirs = HashSet::new(); 80 | for file in WalkDir::new(from.as_ref()).into_iter() { 81 | let file = file?; 82 | 83 | let ty = file.file_type(); 84 | if !ty.is_file() { 85 | continue; 86 | } 87 | if !predicate(&file) { 88 | continue; 89 | } 90 | let src_path = file.path(); 91 | let rel_path = src_path.strip_prefix(from.as_ref())?; 92 | let dst_path = to.as_ref().plus(rel_path); 93 | let dst_dir = dst_path.parent().unwrap().to_path_buf(); 94 | // Avoid making one create_dir_all call per file (could be expensive?) 95 | let dst_dir_does_not_exist = created_dirs.insert(dst_dir.clone()); 96 | if dst_dir_does_not_exist { 97 | std::fs::create_dir_all(&dst_dir)?; 98 | } 99 | std::fs::copy(src_path, &dst_path)?; 100 | } 101 | 102 | Ok(()) 103 | } 104 | -------------------------------------------------------------------------------- /xtask/src/util/targets.rs: -------------------------------------------------------------------------------- 1 | //! Contains information on the various build targets how their build artifacts are named. 2 | 3 | #[derive(Debug, PartialEq, Eq)] 4 | pub struct Target { 5 | /// Cargo target triple for this target 6 | pub target_triple: &'static str, 7 | /// OpenFX architecture string for this target 8 | /// https://openfx.readthedocs.io/en/master/Reference/ofxPackaging.html#installation-directory-hierarchy 9 | pub ofx_architecture: &'static str, 10 | /// File extension for a dynamic library on this platform, excluding the leading dot 11 | pub library_extension: &'static str, 12 | /// Prefix for the library output filename. Platform-dependant; thanks Cargo! 13 | /// On Unix, it's "lib", so e.g. "foo" becomes "libfoo.so" or "libfoo.dylib". On Windows, there's no prefix, so it 14 | /// would just be "foo.dll". 15 | pub library_prefix: &'static str, 16 | } 17 | 18 | // "Supported" target triples 19 | pub const TARGETS: &[Target] = &[ 20 | Target { 21 | target_triple: "x86_64-unknown-linux-gnu", 22 | ofx_architecture: "Linux-x86-64", 23 | library_extension: "so", 24 | library_prefix: "lib", 25 | }, 26 | Target { 27 | target_triple: "i686-unknown-linux-gnu", 28 | ofx_architecture: "Linux-x86", 29 | library_extension: "so", 30 | library_prefix: "lib", 31 | }, 32 | Target { 33 | target_triple: "x86_64-pc-windows-msvc", 34 | ofx_architecture: "Win64", 35 | library_extension: "dll", 36 | library_prefix: "", 37 | }, 38 | Target { 39 | target_triple: "i686-pc-windows-msvc", 40 | ofx_architecture: "Win32", 41 | library_extension: "dll", 42 | library_prefix: "", 43 | }, 44 | Target { 45 | target_triple: "x86_64-apple-darwin", 46 | ofx_architecture: "MacOS", 47 | library_extension: "dylib", 48 | library_prefix: "lib", 49 | }, 50 | Target { 51 | target_triple: "aarch64-apple-darwin", 52 | ofx_architecture: "MacOS", 53 | library_extension: "dylib", 54 | library_prefix: "lib", 55 | }, 56 | ]; 57 | 58 | pub const LINUX_X86_64: &Target = &TARGETS[0]; 59 | pub const LINUX_X86: &Target = &TARGETS[1]; 60 | pub const WINDOWS_X86_64: &Target = &TARGETS[2]; 61 | pub const WINDOWS_I686: &Target = &TARGETS[3]; 62 | pub const MACOS_X86_64: &Target = &TARGETS[4]; 63 | pub const MACOS_AARCH64: &Target = &TARGETS[5]; 64 | --------------------------------------------------------------------------------